Skip to content

Commit a28c4bf

Browse files
authored
frontend overhaul (#23)
1 parent 2308743 commit a28c4bf

29 files changed

+721
-375
lines changed

frontend/src/app/globals.css

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
@tailwind base;
1+
@layer tailwind {
2+
@tailwind base;
3+
}
24
@tailwind components;
35
@tailwind utilities;
46
@layer utilities {
@@ -15,26 +17,23 @@
1517

1618
@layer base {
1719
:root {
18-
--text: #000000;
19-
--background: #ffffff;
20-
--primary: #fce02c;
21-
--secondary: #c4c4c4;
22-
--accent: #fce02c;
23-
--underline: rgba(252, 224, 44, 0.9);
20+
--selected: #3a3a3a;
21+
--background: #1f1f1f;
22+
--secondary: #2e2e2e;
23+
--accent: #ffe22f;
2424
}
2525
.dark {
26-
--text: #ffffff;
27-
--background: #000000;
28-
--primary: #d3b703;
29-
--secondary: #3b3b3b;
30-
--accent: #d3b703;
31-
--underline: rgba(211, 183, 3, 0.9);
26+
--selected: #3a3a3a;
27+
--background: #1f1f1f;
28+
--secondary: #2e2e2e;
29+
--accent: #ffe22f;
3230
}
3331
}
3432

3533
.underline-fancy {
3634
text-decoration: underline;
37-
text-decoration-color: var(--underline);
35+
font-weight: bold;
36+
text-decoration-color: var(--mantine-color-accent-0);
3837
text-underline-offset: 4px;
39-
text-decoration-thickness: 3px;
38+
text-decoration-thickness: 2px;
4039
}

frontend/src/app/jobs/actions.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,25 @@ export interface MongoJob extends Omit<Job, "id"> {
1212
_id: ObjectId;
1313
}
1414

15-
export async function getJobs(filters: Partial<JobFilters>): Promise<Job[]> {
15+
/**
16+
* Fetches paginated and filtered job listings from MongoDB.
17+
*
18+
* @param filters - Partial JobFilters object containing:
19+
* - workingRights?: Array of required working rights
20+
* - jobTypes?: Array of job types to include
21+
* - industryFields?: Array of industry fields
22+
* - search?: Full-text search on job titles and company names (case-insensitive)
23+
* - page?: Page number (defaults to 1)
24+
*
25+
* @returns Promise containing:
26+
* - jobs: Array of serialized Job objects
27+
* - total: Total count of jobs matching the filters
28+
*
29+
* @throws Error if MongoDB connection fails or if MONGODB_URI is not configured
30+
*/
31+
export async function getJobs(
32+
filters: Partial<JobFilters>,
33+
): Promise<{ jobs: Job[]; total: number }> {
1634
if (!process.env.MONGODB_URI) {
1735
throw new Error(
1836
"MongoDB URI is not configured. Please check environment variables.",
@@ -28,11 +46,25 @@ export async function getJobs(filters: Partial<JobFilters>): Promise<Job[]> {
2846
const query = {
2947
outdated: false,
3048
...(filters.workingRights?.length && {
31-
working_rights: { $in: filters.workingRights },
49+
working_rights: {
50+
$in: Array.isArray(filters.workingRights)
51+
? filters.workingRights
52+
: [filters.workingRights],
53+
},
54+
}),
55+
...(filters.jobTypes?.length && {
56+
type: {
57+
$in: Array.isArray(filters.jobTypes)
58+
? filters.jobTypes
59+
: [filters.jobTypes],
60+
},
3261
}),
33-
...(filters.jobTypes?.length && { type: { $in: filters.jobTypes } }),
3462
...(filters.industryFields?.length && {
35-
industry_field: { $in: filters.industryFields },
63+
industry_field: {
64+
$in: Array.isArray(filters.industryFields)
65+
? filters.industryFields
66+
: [filters.industryFields],
67+
},
3668
}),
3769
...(filters.search && {
3870
$or: [
@@ -45,13 +77,15 @@ export async function getJobs(filters: Partial<JobFilters>): Promise<Job[]> {
4577
const page = filters.page || 1;
4678
const skip = (page - 1) * PAGE_SIZE;
4779

48-
const jobs = (await collection
49-
.find(query)
50-
.skip(skip)
51-
.limit(PAGE_SIZE)
52-
.toArray()) as MongoJob[];
80+
const [jobs, total] = await Promise.all([
81+
collection.find(query).skip(skip).limit(PAGE_SIZE).toArray(),
82+
collection.countDocuments(query),
83+
]);
5384

54-
return jobs.map(serializeJob);
85+
return {
86+
jobs: (jobs as MongoJob[]).map(serializeJob),
87+
total,
88+
};
5589
} catch (error) {
5690
console.error("Server Error:", {
5791
error,

frontend/src/app/jobs/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { PropsWithChildren } from "react";
44
export default function JobsLayout({ children }: PropsWithChildren) {
55
return (
66
<FilterProvider>
7-
<div className="max-w-7xl mx-auto px-4 py-6">{children}</div>
7+
<div className="max-w-7xl mx-auto">{children}</div>
88
</FilterProvider>
99
);
1010
}

frontend/src/app/jobs/loading.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// frontend/src/app/jobs/loading.tsx
12
export default function JobLoading() {
23
return <div>Loading Job...</div>;
34
}

frontend/src/app/jobs/page.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
// frontend/src/app/jobs/page.tsx
12
import SearchBar from "@/components/jobs/search/search-bar";
23
import FilterSection from "@/components/jobs/filters/filter-section";
34
import JobList from "@/components/jobs/details/job-list";
45
import JobDetails from "@/components/jobs/details/job-details";
5-
import { Title } from "@mantine/core";
66
import { JobFilters } from "@/types/filters";
77
import { getJobs } from "@/app/jobs/actions";
8+
import JobPagination from "@/components/jobs/job-pagination";
9+
import { Suspense } from "react";
10+
import Loading from "@/app/loading";
11+
import HeadingText from "@/components/layout/heading-text";
812

913
export default async function JobsPage({
1014
searchParams,
@@ -14,30 +18,30 @@ export default async function JobsPage({
1418
// https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional
1519
// searchParams is a promise that resolves to an object containing the search
1620
// parameters of the current URL.
17-
const jobs = await getJobs(await searchParams);
21+
const { jobs, total } = await getJobs(await searchParams);
1822

1923
return (
20-
<div className="space-y-4">
21-
<Title>
22-
<span className="font-light">Find</span>{" "}
23-
<span className="underline-fancy">Internships</span>{" "}
24-
<span className="font-light">and</span>{" "}
25-
<span className="underline-fancy">Student Jobs</span>
26-
</Title>
24+
<div className="">
25+
<HeadingText />
2726
<SearchBar />
28-
<FilterSection />
27+
<FilterSection _totalJobs={total} />
2928

30-
<div className="mt-4 flex flex-col lg:flex-row gap-2 h-[calc(100vh-330px)] ">
31-
<div className="w-full lg:w-[35%] overflow-y-auto pr-2 no-scrollbar">
32-
<JobList />
33-
</div>
29+
<Suspense fallback={<Loading />}>
30+
<div className="mt-4 flex flex-col lg:flex-row gap-2">
31+
<div className="w-full lg:w-[35%]">
32+
<div className="overflow-y-auto pr-2 no-scrollbar h-[calc(100vh-330px)]">
33+
<JobList jobs={jobs} />
34+
<JobPagination />
35+
</div>
36+
</div>
3437

35-
<div className="hidden lg:block lg:w-[65%]">
36-
<div className="overflow-y-auto h-[calc(100vh-330px)]">
37-
<JobDetails job={jobs[0]} />
38+
<div className="hidden lg:block lg:w-[65%]">
39+
<div className="overflow-y-auto h-[calc(100vh-330px)]">
40+
<JobDetails />
41+
</div>
3842
</div>
3943
</div>
40-
</div>
44+
</Suspense>
4145
</div>
4246
);
4347
}

frontend/src/app/layout.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
1-
import NavBar from "@/components/layout/nav-bar";
2-
import "./globals.css";
1+
// Order seems to matter. If Mantine is imported after tailwind,
2+
// the tailwind class passed with className is not applied.
33
import "@mantine/core/styles.css";
4+
import "./globals.css";
5+
6+
import NavBar from "@/components/layout/nav-bar";
47
import { MantineProvider } from "@mantine/core";
58
import { ColorSchemeScript } from "@mantine/core";
69
import { PropsWithChildren } from "react";
710
import Head from "next/head";
811
import { theme } from "@/lib/theme";
912

13+
import { Poppins } from "next/font/google";
14+
15+
const poppins = Poppins({
16+
subsets: ["latin"],
17+
weight: ["400", "500", "600", "700"],
18+
});
19+
1020
export default function RootLayout({ children }: PropsWithChildren) {
1121
return (
1222
<html lang="en" data-theme="dark">
1323
<Head>
1424
<ColorSchemeScript defaultColorScheme="dark" />
1525
</Head>
16-
<body className="text-text dark">
26+
<body className={`${poppins.className}`}>
1727
<MantineProvider theme={theme} defaultColorScheme="dark">
18-
<div className="min-h-screen flex flex-col">
28+
<div className="min-h-screen flex flex-col px-6">
1929
<NavBar />
2030
<main className="flex-grow">{children}</main>
2131
</div>
Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,56 @@
1-
import { Text } from "@mantine/core";
1+
// frontend/src/components/jobs/details/job-card.tsx
2+
import { Box, Image } from "@mantine/core";
3+
import { Job } from "@/types/job";
4+
import { IconMapPin } from "@tabler/icons-react";
5+
import { formatCapString, getTimeAgo } from "@/lib/utils";
6+
import Badge from "@/components/ui/badge";
27

3-
export default function JobCard() {
8+
interface JobCardProps {
9+
job: Job;
10+
isSelected?: boolean;
11+
}
12+
13+
export default function JobCard({ job, isSelected }: JobCardProps) {
414
return (
5-
<div className="bg-neutral-700 p-4 rounded-lg mb-4">
6-
<Text>Job Title</Text>
7-
<Text>Location</Text>
8-
<Text>
9-
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean
10-
tincidunt urna ac luctus pellentesque.{" "}
11-
</Text>
12-
</div>
15+
<Box
16+
bg={isSelected ? "selected" : "secondary"}
17+
bd="2px solid selected"
18+
className={`h-[10rem] p-4 rounded-xl transition-colors`}
19+
>
20+
<div className={"flex justify-between"}>
21+
<div className={"flex"}>
22+
<Image
23+
alt={job.company.name}
24+
src={job.company.logo}
25+
className={"mr-2 h-14 w-14 object-contain rounded-md bg-white"}
26+
/>
27+
<div className={"flex justify-center flex-col max-w-64 space-y-0.5"}>
28+
<span className="text-md font-bold truncate leading-tight">
29+
{job.title}
30+
</span>
31+
<span className="text-xs truncate">{job.company.name}</span>
32+
<span className="text-xs flex items-center gap-1">
33+
<IconMapPin size={12} />
34+
{formatCapString(job.locations[0])}
35+
</span>
36+
</div>
37+
</div>
38+
<span className={"text-xs"}>{getTimeAgo(job.updated_at)}</span>
39+
</div>
40+
<div className={"text-xs line-clamp-2 mt-2"}>{job.description}</div>
41+
<div className={"mt-2 flex gap-2"}>
42+
{job.type && <Badge text={formatCapString(job.type)} />}
43+
{job.working_rights?.[0] && (
44+
<Badge
45+
text={
46+
job.working_rights[0] === "VISA_SPONSORED"
47+
? "Visa-Friendly"
48+
: "Citizen/PR"
49+
}
50+
/>
51+
)}
52+
<Badge text="Banking" />
53+
</div>
54+
</Box>
1355
);
1456
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// frontend/src/components/jobs/details/sections/job-description.tsx
2+
import SectionHeading from "@/components/ui/section-heading";
3+
import DOMPurify from "isomorphic-dompurify";
4+
5+
interface JobDescriptionProps {
6+
description: string;
7+
}
8+
9+
export default function JobDescription({ description }: JobDescriptionProps) {
10+
return (
11+
<div className="flex flex-col space-y-1 mt-8">
12+
<SectionHeading title="Job Description" />
13+
<div
14+
dangerouslySetInnerHTML={{
15+
__html: DOMPurify.sanitize(description || ""),
16+
}}
17+
className="text-sm leading-relaxed"
18+
/>
19+
</div>
20+
);
21+
}

0 commit comments

Comments
 (0)