Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@tailwind base;
@layer tailwind {
@tailwind base;
}
@tailwind components;
@tailwind utilities;
@layer utilities {
Expand All @@ -15,26 +17,23 @@

@layer base {
:root {
--text: #000000;
--background: #ffffff;
--primary: #fce02c;
--secondary: #c4c4c4;
--accent: #fce02c;
--underline: rgba(252, 224, 44, 0.9);
--selected: #3a3a3a;
--background: #1f1f1f;
--secondary: #2e2e2e;
--accent: #ffe22f;
}
.dark {
--text: #ffffff;
--background: #000000;
--primary: #d3b703;
--secondary: #3b3b3b;
--accent: #d3b703;
--underline: rgba(211, 183, 3, 0.9);
--selected: #3a3a3a;
--background: #1f1f1f;
--secondary: #2e2e2e;
--accent: #ffe22f;
}
}

.underline-fancy {
text-decoration: underline;
text-decoration-color: var(--underline);
font-weight: bold;
text-decoration-color: var(--mantine-color-accent-0);
text-underline-offset: 4px;
text-decoration-thickness: 3px;
text-decoration-thickness: 2px;
}
54 changes: 44 additions & 10 deletions frontend/src/app/jobs/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,25 @@ export interface MongoJob extends Omit<Job, "id"> {
_id: ObjectId;
}

export async function getJobs(filters: Partial<JobFilters>): Promise<Job[]> {
/**
* Fetches paginated and filtered job listings from MongoDB.
*
* @param filters - Partial JobFilters object containing:
* - workingRights?: Array of required working rights
* - jobTypes?: Array of job types to include
* - industryFields?: Array of industry fields
* - search?: Full-text search on job titles and company names (case-insensitive)
* - page?: Page number (defaults to 1)
*
* @returns Promise containing:
* - jobs: Array of serialized Job objects
* - total: Total count of jobs matching the filters
*
* @throws Error if MongoDB connection fails or if MONGODB_URI is not configured
*/
export async function getJobs(
filters: Partial<JobFilters>,
): Promise<{ jobs: Job[]; total: number }> {
if (!process.env.MONGODB_URI) {
throw new Error(
"MongoDB URI is not configured. Please check environment variables.",
Expand All @@ -28,11 +46,25 @@ export async function getJobs(filters: Partial<JobFilters>): Promise<Job[]> {
const query = {
outdated: false,
...(filters.workingRights?.length && {
working_rights: { $in: filters.workingRights },
working_rights: {
$in: Array.isArray(filters.workingRights)
? filters.workingRights
: [filters.workingRights],
},
}),
...(filters.jobTypes?.length && {
type: {
$in: Array.isArray(filters.jobTypes)
? filters.jobTypes
: [filters.jobTypes],
},
}),
...(filters.jobTypes?.length && { type: { $in: filters.jobTypes } }),
...(filters.industryFields?.length && {
industry_field: { $in: filters.industryFields },
industry_field: {
$in: Array.isArray(filters.industryFields)
? filters.industryFields
: [filters.industryFields],
},
}),
...(filters.search && {
$or: [
Expand All @@ -45,13 +77,15 @@ export async function getJobs(filters: Partial<JobFilters>): Promise<Job[]> {
const page = filters.page || 1;
const skip = (page - 1) * PAGE_SIZE;

const jobs = (await collection
.find(query)
.skip(skip)
.limit(PAGE_SIZE)
.toArray()) as MongoJob[];
const [jobs, total] = await Promise.all([
collection.find(query).skip(skip).limit(PAGE_SIZE).toArray(),
collection.countDocuments(query),
]);

return jobs.map(serializeJob);
return {
jobs: (jobs as MongoJob[]).map(serializeJob),
total,
};
} catch (error) {
console.error("Server Error:", {
error,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/jobs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PropsWithChildren } from "react";
export default function JobsLayout({ children }: PropsWithChildren) {
return (
<FilterProvider>
<div className="max-w-7xl mx-auto px-4 py-6">{children}</div>
<div className="max-w-7xl mx-auto">{children}</div>
</FilterProvider>
);
}
1 change: 1 addition & 0 deletions frontend/src/app/jobs/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// frontend/src/app/jobs/loading.tsx
export default function JobLoading() {
return <div>Loading Job...</div>;
}
40 changes: 22 additions & 18 deletions frontend/src/app/jobs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// frontend/src/app/jobs/page.tsx
import SearchBar from "@/components/jobs/search/search-bar";
import FilterSection from "@/components/jobs/filters/filter-section";
import JobList from "@/components/jobs/details/job-list";
import JobDetails from "@/components/jobs/details/job-details";
import { Title } from "@mantine/core";
import { JobFilters } from "@/types/filters";
import { getJobs } from "@/app/jobs/actions";
import JobPagination from "@/components/jobs/job-pagination";
import { Suspense } from "react";
import Loading from "@/app/loading";
import HeadingText from "@/components/layout/heading-text";

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

return (
<div className="space-y-4">
<Title>
<span className="font-light">Find</span>{" "}
<span className="underline-fancy">Internships</span>{" "}
<span className="font-light">and</span>{" "}
<span className="underline-fancy">Student Jobs</span>
</Title>
<div className="">
<HeadingText />
<SearchBar />
<FilterSection />
<FilterSection _totalJobs={total} />

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

<div className="hidden lg:block lg:w-[65%]">
<div className="overflow-y-auto h-[calc(100vh-330px)]">
<JobDetails job={jobs[0]} />
<div className="hidden lg:block lg:w-[65%]">
<div className="overflow-y-auto h-[calc(100vh-330px)]">
<JobDetails />
</div>
</div>
</div>
</div>
</Suspense>
</div>
);
}
18 changes: 14 additions & 4 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import NavBar from "@/components/layout/nav-bar";
import "./globals.css";
// Order seems to matter. If Mantine is imported after tailwind,
// the tailwind class passed with className is not applied.
import "@mantine/core/styles.css";
import "./globals.css";

import NavBar from "@/components/layout/nav-bar";
import { MantineProvider } from "@mantine/core";
import { ColorSchemeScript } from "@mantine/core";
import { PropsWithChildren } from "react";
import Head from "next/head";
import { theme } from "@/lib/theme";

import { Poppins } from "next/font/google";

const poppins = Poppins({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});

export default function RootLayout({ children }: PropsWithChildren) {
return (
<html lang="en" data-theme="dark">
<Head>
<ColorSchemeScript defaultColorScheme="dark" />
</Head>
<body className="text-text dark">
<body className={`${poppins.className}`}>
<MantineProvider theme={theme} defaultColorScheme="dark">
<div className="min-h-screen flex flex-col">
<div className="min-h-screen flex flex-col px-6">
<NavBar />
<main className="flex-grow">{children}</main>
</div>
Expand Down
62 changes: 52 additions & 10 deletions frontend/src/components/jobs/details/job-card.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,56 @@
import { Text } from "@mantine/core";
// frontend/src/components/jobs/details/job-card.tsx
import { Box, Image } from "@mantine/core";
import { Job } from "@/types/job";
import { IconMapPin } from "@tabler/icons-react";
import { formatCapString, getTimeAgo } from "@/lib/utils";
import Badge from "@/components/ui/badge";

export default function JobCard() {
interface JobCardProps {
job: Job;
isSelected?: boolean;
}

export default function JobCard({ job, isSelected }: JobCardProps) {
return (
<div className="bg-neutral-700 p-4 rounded-lg mb-4">
<Text>Job Title</Text>
<Text>Location</Text>
<Text>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean
tincidunt urna ac luctus pellentesque.{" "}
</Text>
</div>
<Box
bg={isSelected ? "selected" : "secondary"}
bd="2px solid selected"
className={`h-[10rem] p-4 rounded-xl transition-colors`}
>
<div className={"flex justify-between"}>
<div className={"flex"}>
<Image
alt={job.company.name}
src={job.company.logo}
className={"mr-2 h-14 w-14 object-contain rounded-md bg-white"}
/>
<div className={"flex justify-center flex-col max-w-64 space-y-0.5"}>
<span className="text-md font-bold truncate leading-tight">
{job.title}
</span>
<span className="text-xs truncate">{job.company.name}</span>
<span className="text-xs flex items-center gap-1">
<IconMapPin size={12} />
{formatCapString(job.locations[0])}
</span>
</div>
</div>
<span className={"text-xs"}>{getTimeAgo(job.updated_at)}</span>
</div>
<div className={"text-xs line-clamp-2 mt-2"}>{job.description}</div>
<div className={"mt-2 flex gap-2"}>
{job.type && <Badge text={formatCapString(job.type)} />}
{job.working_rights?.[0] && (
<Badge
text={
job.working_rights[0] === "VISA_SPONSORED"
? "Visa-Friendly"
: "Citizen/PR"
}
/>
)}
<Badge text="Banking" />
</div>
</Box>
);
}
21 changes: 21 additions & 0 deletions frontend/src/components/jobs/details/job-description.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// frontend/src/components/jobs/details/sections/job-description.tsx
import SectionHeading from "@/components/ui/section-heading";
import DOMPurify from "isomorphic-dompurify";

interface JobDescriptionProps {
description: string;
}

export default function JobDescription({ description }: JobDescriptionProps) {
return (
<div className="flex flex-col space-y-1 mt-8">
<SectionHeading title="Job Description" />
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(description || ""),
}}
className="text-sm leading-relaxed"
/>
</div>
);
}
Loading
Loading