From d8828207f2e4e84c6220e4326d5e0862cbe10580 Mon Sep 17 00:00:00 2001 From: Ed Date: Mon, 27 Jan 2025 05:18:29 +1100 Subject: [PATCH 01/11] 5am coding - i have no idea what I changed, Ill go through it later --- frontend/src/app/globals.css | 5 +- frontend/src/app/jobs/actions.ts | 18 +-- frontend/src/app/jobs/loading.tsx | 35 +++++- frontend/src/app/jobs/page.tsx | 32 +++--- frontend/src/app/layout.tsx | 2 +- frontend/src/app/loading.tsx | 15 ++- .../src/components/jobs/details/job-card.tsx | 107 ++++++++++++++++-- .../components/jobs/details/job-details.tsx | 74 +++++------- .../src/components/jobs/details/job-list.tsx | 41 +++++-- .../src/components/jobs/job-pagination.tsx | 42 +++++++ .../src/components/jobs/search/search-bar.tsx | 16 +++ .../src/context/filter/filter-context.tsx | 4 + .../src/context/filter/filter-provider.tsx | 25 +++- frontend/src/lib/theme.ts | 12 ++ frontend/src/lib/utils.ts | 41 ++++--- frontend/src/types/job.ts | 16 ++- frontend/tailwind.config.ts | 1 + 17 files changed, 368 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/jobs/job-pagination.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d5a954b..141f40e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -24,9 +24,10 @@ } .dark { --text: #ffffff; - --background: #000000; + --background: #1f1f1f; --primary: #d3b703; - --secondary: #3b3b3b; + --secondary: #2e2e2e; + --selected: #3a3a3a; --accent: #d3b703; --underline: rgba(211, 183, 3, 0.9); } diff --git a/frontend/src/app/jobs/actions.ts b/frontend/src/app/jobs/actions.ts index 00f364c..ecba895 100644 --- a/frontend/src/app/jobs/actions.ts +++ b/frontend/src/app/jobs/actions.ts @@ -12,7 +12,9 @@ export interface MongoJob extends Omit { _id: ObjectId; } -export async function getJobs(filters: Partial): Promise { +export async function getJobs( + filters: Partial, +): Promise<{ jobs: Job[]; total: number }> { if (!process.env.MONGODB_URI) { throw new Error( "MongoDB URI is not configured. Please check environment variables.", @@ -45,13 +47,15 @@ export async function getJobs(filters: Partial): Promise { 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, diff --git a/frontend/src/app/jobs/loading.tsx b/frontend/src/app/jobs/loading.tsx index 04cf754..1ae84d7 100644 --- a/frontend/src/app/jobs/loading.tsx +++ b/frontend/src/app/jobs/loading.tsx @@ -1,3 +1,36 @@ +// frontend/src/app/jobs/loading.tsx export default function JobLoading() { - return
Loading Job...
; + return ( +
+ {/* Header skeleton */} +
+ + {/* Search bar skeleton */} +
+ + {/* Filter section skeleton */} +
+
+
+
+ + {/* Job list and details skeleton */} +
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ +
+
+
+
+
+ ); } diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index 1267b1a..33aca91 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -1,20 +1,21 @@ +// frontend/src/app/jobs/page.tsx +import { Title } from "@mantine/core"; 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"; export default async function JobsPage({ searchParams, }: { searchParams: Promise>; }) { - // 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 (
@@ -27,17 +28,22 @@ export default async function JobsPage({ -
-
- -
+ }> +
+
+
+ +
+ +
-
-
- +
+
+ +
-
+
); } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b77f6d1..86e4ffe 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -13,7 +13,7 @@ export default function RootLayout({ children }: PropsWithChildren) { - +
diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx index fc80ef0..4d593e6 100644 --- a/frontend/src/app/loading.tsx +++ b/frontend/src/app/loading.tsx @@ -1,3 +1,16 @@ export default function Loading() { - return
Loading...
; + return ( +
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+
+ ); } diff --git a/frontend/src/components/jobs/details/job-card.tsx b/frontend/src/components/jobs/details/job-card.tsx index 7c1c87e..97132db 100644 --- a/frontend/src/components/jobs/details/job-card.tsx +++ b/frontend/src/components/jobs/details/job-card.tsx @@ -1,14 +1,103 @@ -import { Text } from "@mantine/core"; +// frontend/src/components/jobs/details/job-card.tsx +import { Badge, Image } from "@mantine/core"; +import { Job } from "@/types/job"; +import { IconMapPin } from "@tabler/icons-react"; -export default function JobCard() { +interface JobCardProps { + job: Job; + isSelected?: boolean; +} + +function truncateText(text: string, maxLength: number) { + if (!text) return ""; + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; +} + +function getTimeAgo(dateString: string) { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return `${diffDays}d ago`; +} + +export default function JobCard({ job, isSelected }: JobCardProps) { return ( -
- Job Title - Location - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean - tincidunt urna ac luctus pellentesque.{" "} - +
+ {/* Header Section */} +
+ {/* Logo and Title Section */} +
+ {job.company.name} + +
+

{job.title}

+

{job.company.name}

+
+ +

{job.locations[0]}

+
+
+
+ + {/* Posted Time */} + + {getTimeAgo(job.updated_at)} + +
+ + {/* Description Section */} +

+ {truncateText(job.description || "", 150)} +

+ + {/* Tags Section */} +
+ {job.type && ( + + {job.type} + + )} + {job.working_rights?.[0] && ( + + {job.working_rights[0] === "VISA_SPONSORED" + ? "Visa-Friendly" + : "Citizen/PR"} + + )} + {job.industry && ( + + {job.industry} + + )} +
); } diff --git a/frontend/src/components/jobs/details/job-details.tsx b/frontend/src/components/jobs/details/job-details.tsx index bdae379..99e5f50 100644 --- a/frontend/src/components/jobs/details/job-details.tsx +++ b/frontend/src/components/jobs/details/job-details.tsx @@ -14,18 +14,10 @@ import { Box, Flex, } from "@mantine/core"; -import { - IconMapPin, - IconPencil, - IconBook2, - IconBriefcase2, -} from "@tabler/icons-react"; +import { IconMapPin, IconPencil, IconBriefcase2 } from "@tabler/icons-react"; import DOMPurify from "isomorphic-dompurify"; -import { Job } from "@/types/job"; - -interface JobDetailsProps { - job: Job; -} +import { useFilterContext } from "@/context/filter/filter-context"; +import { useEffect } from "react"; function formatISODate(isoDate: string): string { const date = new Date(isoDate); @@ -41,13 +33,22 @@ function sanitizeHtml(html: string) { return DOMPurify.sanitize(html); } -export default function JobDetails({ job }: JobDetailsProps) { +export default function JobDetails() { + const { selectedJob, isLoading } = useFilterContext(); + if (!selectedJob || isLoading) { + return ( +
+
+
+ ); + } + const handleApplyClick = () => { - window.open(job.applicationUrl, "_blank"); // Open link in a new tab + window.open(selectedJob.application_url, "_blank"); // Open link in a new tab }; return ( - + ({ paddingLeft: theme.spacing.md, @@ -62,8 +63,8 @@ export default function JobDetails({ job }: JobDetailsProps) { {/* Logo and Company Name */} {job.company.name} - {job.company.name} + {selectedJob.company.name} @@ -96,7 +97,7 @@ export default function JobDetails({ job }: JobDetailsProps) { {/* Job Title */} - {job.title} + {selectedJob.title} {/* Job Information section */} - {job.locations?.map((location) => ( + {selectedJob.locations?.map((location) => ( {location} @@ -131,11 +132,13 @@ export default function JobDetails({ job }: JobDetailsProps) { /> {/* Post Date */} - Posted {formatISODate(job.createdAt)} + + Posted {formatISODate(selectedJob.created_at)} + {/* Job Type */} - {job.type} + {selectedJob.type} @@ -177,37 +180,12 @@ export default function JobDetails({ job }: JobDetailsProps) { >
- {/* Study Field Section */} - - - - - Study Fields - - - - {job.studyFields?.map((field) => ( - - {field} - - ))} - - - {/* Working Rights Section */} @@ -225,7 +203,7 @@ export default function JobDetails({ job }: JobDetailsProps) { - {job.workingRights?.map((rights) => ( + {selectedJob.working_rights?.map((rights) => ( {rights} diff --git a/frontend/src/components/jobs/details/job-list.tsx b/frontend/src/components/jobs/details/job-list.tsx index c605218..7e559c2 100644 --- a/frontend/src/components/jobs/details/job-list.tsx +++ b/frontend/src/components/jobs/details/job-list.tsx @@ -1,13 +1,40 @@ +// frontend/src/components/jobs/details/job-list.tsx +"use client"; + import JobCard from "@/components/jobs/details/job-card"; +import { useFilterContext } from "@/context/filter/filter-context"; +import { Job } from "@/types/job"; +import { useEffect } from "react"; +import Loading from "@/app/loading"; + +interface JobListProps { + jobs: Job[]; +} + +export default function JobList({ jobs }: JobListProps) { + const { selectedJob, setSelectedJob, isLoading } = useFilterContext(); + + useEffect(() => { + if (jobs.length > 0 && !selectedJob) { + setSelectedJob(jobs[0]); + } + }, [jobs]); + + if (isLoading) { + return ; + } -export default function JobList() { return ( -
- {Array(20) - .fill(0) - .map((_, i) => ( - - ))} +
+ {jobs.map((job) => ( +
setSelectedJob(job)} + className="cursor-pointer" + > + +
+ ))}
); } diff --git a/frontend/src/components/jobs/job-pagination.tsx b/frontend/src/components/jobs/job-pagination.tsx new file mode 100644 index 0000000..96df8c6 --- /dev/null +++ b/frontend/src/components/jobs/job-pagination.tsx @@ -0,0 +1,42 @@ +// frontend/src/components/jobs/pagination.tsx +"use client"; + +import { Pagination } from "@mantine/core"; +import { useFilterContext } from "@/context/filter/filter-context"; + +interface JobPaginationProps { + totalJobs: number; + pageSize?: number; +} + +export default function JobPagination({ + totalJobs, + pageSize = 20, +}: JobPaginationProps) { + const { filters, updateFilters } = useFilterContext(); + const totalPages = Math.ceil(totalJobs / pageSize); + + if (totalPages <= 1) return null; + + const handlePageChange = (page: number) => { + updateFilters({ + filters: { + ...filters.filters, + page, + }, + }); + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/jobs/search/search-bar.tsx b/frontend/src/components/jobs/search/search-bar.tsx index 6eaded5..46897f5 100644 --- a/frontend/src/components/jobs/search/search-bar.tsx +++ b/frontend/src/components/jobs/search/search-bar.tsx @@ -1,12 +1,27 @@ "use client"; import { Input } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; +import { useFilterContext } from "@/context/filter/filter-context"; +import { useDebouncedCallback } from "@mantine/hooks"; export default function SearchBar() { + const { filters, updateFilters } = useFilterContext(); + + const handleSearch = useDebouncedCallback((value: string) => { + updateFilters({ + filters: { + ...filters.filters, + search: value, + page: 1, // Reset to first page on new search + }, + }); + }, 300); + return ( } + onChange={(e) => handleSearch(e.currentTarget.value)} rightSection={ diff --git a/frontend/src/context/filter/filter-context.tsx b/frontend/src/context/filter/filter-context.tsx index 6bcdde1..d0d2e85 100644 --- a/frontend/src/context/filter/filter-context.tsx +++ b/frontend/src/context/filter/filter-context.tsx @@ -3,10 +3,14 @@ // frontend/src/context/jobs/filter-context.tsx import { createContext, useContext } from "react"; import { FilterState } from "@/types/filters"; +import { Job } from "@/types/job"; interface FilterContextType { filters: FilterState; updateFilters: (filters: Partial) => void; + selectedJob: Job | null; + setSelectedJob: (job: Job | null) => void; + isLoading: boolean; } export const FilterContext = createContext( diff --git a/frontend/src/context/filter/filter-provider.tsx b/frontend/src/context/filter/filter-provider.tsx index fe69185..38db62d 100644 --- a/frontend/src/context/filter/filter-provider.tsx +++ b/frontend/src/context/filter/filter-provider.tsx @@ -1,11 +1,12 @@ // frontend/src/context/jobs/jobs-provider.tsx "use client"; -import { ReactNode, useState } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { FilterContext } from "./filter-context"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateQueryString } from "@/lib/utils"; import { FilterState, SortBy } from "@/types/filters"; +import { Job } from "@/types/job"; const emptyFilterState: FilterState = { filters: { @@ -24,16 +25,34 @@ const emptyFilterState: FilterState = { export function FilterProvider({ children }: { children: ReactNode }) { const [filters, setFilters] = useState(emptyFilterState); + const [selectedJob, setSelectedJob] = useState(null); + const [isLoading, setIsLoading] = useState(false); const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const updateFilters = (newFilters: Partial) => { + setIsLoading(true); setFilters((curr) => ({ ...curr, ...newFilters })); + setSelectedJob(null); const params = CreateQueryString(newFilters); router.push(`/jobs?${params}`); }; + useEffect(() => { + setIsLoading(false); + }, [pathname, searchParams]); + return ( - + {children} ); diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index 2ec6ee2..8acf15c 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -10,6 +10,18 @@ const theme = createTheme({ background: colorsTuple(twConfig.theme.colors.background), secondary: colorsTuple(twConfig.theme.colors.secondary), accent: colorsTuple(twConfig.theme.colors.accent), + dark: [ + "#c9c9c9", + "#b8b8b8", + "#828282", + "#696969", + "#424242", + "#3a3a3a", + "#2e2e2e", + "#242424", + "#1f1f1f", + "#141414", + ], }, }); theme.primaryColor = "secondary"; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 10e7a91..9cf5437 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -7,21 +7,29 @@ import { MongoJob } from "@/app/jobs/actions"; * Called when the user updates the filter state to generate the new * URL to navigate to. * - * @param filters - Partial FilterState containing the filter parameters to convert + * @param filterState - Partial FilterState containing the filter parameters to convert * @returns A URL-encoded query string * */ -export function CreateQueryString(filters: Partial): string { +export function CreateQueryString(filterState: Partial): string { const params = new URLSearchParams(); - for (const [key, value] of Object.entries(filters)) { - if (!value) continue; + // Handle the nested filters object + if (filterState.filters) { + const filters = filterState.filters; - if (Array.isArray(value)) { - value.forEach((v) => params.append(key, v)); - } else { - params.set(key, value.toString()); - } + // Process each filter field + Object.entries(filters).forEach(([key, value]) => { + if (!value || (Array.isArray(value) && value.length === 0)) return; + + if (Array.isArray(value)) { + // Handle array values (e.g., industryFields, jobTypes) + value.forEach((v) => params.append(key, v)); + } else { + // Handle scalar values (e.g., search, page, sortBy) + params.set(key, value.toString()); + } + }); } return params.toString(); @@ -56,16 +64,15 @@ export default function serializeJob(job: MongoJob): Job { id: job._id.toString(), title: job.title, company: job.company, - sourceUrls: job.sourceUrls, + source_urls: job.source_urls, locations: job.locations, - studyFields: job.studyFields, - industryField: job.industryField, - workingRights: job.workingRights, - createdAt: serializeDate(job.createdAt), - updatedAt: serializeDate(job.updatedAt), + industry: job.industry, + working_rights: job.working_rights, + created_at: serializeDate(job.created_at), + updated_at: serializeDate(job.updated_at), type: job.type, description: job.description, - applicationUrl: job.applicationUrl, - closeDate: serializeDate(job.closeDate), + application_url: job.application_url, + close_date: serializeDate(job.close_date), }; } diff --git a/frontend/src/types/job.ts b/frontend/src/types/job.ts index 8098871..c33c829 100644 --- a/frontend/src/types/job.ts +++ b/frontend/src/types/job.ts @@ -49,15 +49,13 @@ export interface Job { title: string; description?: string; company: Company; - applicationUrl?: string; - sourceUrls: string[]; + application_url?: string; + source_urls: string[]; type?: JobType; - openDate?: string; - closeDate?: string; + close_date?: string; locations: LocationType[]; - studyFields: string[]; - industryField: IndustryField; - workingRights: WorkingRight[]; - createdAt: string; - updatedAt: string; + industry: IndustryField; + working_rights: WorkingRight[]; + created_at: string; + updated_at: string; } diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index c2dba19..85bea26 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -16,6 +16,7 @@ export default { secondary: "var(--secondary)", accent: "var(--accent)", underline: "var(--underline)", + selected: "var(--selected)", }, }, }, From 1a2be659eff28463c3e972c9df5d460c7abaf3e7 Mon Sep 17 00:00:00 2001 From: Ed Date: Mon, 27 Jan 2025 19:39:23 +1100 Subject: [PATCH 02/11] apply poppins font --- frontend/src/app/layout.tsx | 13 ++++++++++--- frontend/src/lib/theme.ts | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 86e4ffe..125f7bf 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,19 +1,26 @@ -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", "700"] }); + export default function RootLayout({ children }: PropsWithChildren) { return ( - +
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index 8acf15c..045c2f9 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -4,6 +4,7 @@ import tailwindConfig from "@/../tailwind.config"; const twConfig = resolveConfig(tailwindConfig); const theme = createTheme({ + fontFamily: "Poppins", colors: { primary: colorsTuple(twConfig.theme.colors.primary), text: colorsTuple(twConfig.theme.colors.text), From be3151c208072ce053d205ec9e580f5b4d057009 Mon Sep 17 00:00:00 2001 From: Ed Date: Mon, 27 Jan 2025 19:41:38 +1100 Subject: [PATCH 03/11] re-implement job-card and add mode switch --- frontend/src/app/globals.css | 31 +---- .../src/components/jobs/details/job-card.tsx | 107 +++++------------- frontend/src/components/layout/nav-bar.tsx | 9 +- frontend/src/lib/theme.ts | 57 ++++++---- frontend/src/lib/utils.ts | 32 ++++++ frontend/tailwind.config.ts | 13 --- 6 files changed, 109 insertions(+), 140 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 141f40e..a554699 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,4 +1,6 @@ -@tailwind base; +@layer tailwind { + @tailwind base; +} @tailwind components; @tailwind utilities; @layer utilities { @@ -12,30 +14,3 @@ scrollbar-width: none; /* Firefox */ } } - -@layer base { - :root { - --text: #000000; - --background: #ffffff; - --primary: #fce02c; - --secondary: #c4c4c4; - --accent: #fce02c; - --underline: rgba(252, 224, 44, 0.9); - } - .dark { - --text: #ffffff; - --background: #1f1f1f; - --primary: #d3b703; - --secondary: #2e2e2e; - --selected: #3a3a3a; - --accent: #d3b703; - --underline: rgba(211, 183, 3, 0.9); - } -} - -.underline-fancy { - text-decoration: underline; - text-decoration-color: var(--underline); - text-underline-offset: 4px; - text-decoration-thickness: 3px; -} diff --git a/frontend/src/components/jobs/details/job-card.tsx b/frontend/src/components/jobs/details/job-card.tsx index 97132db..c535210 100644 --- a/frontend/src/components/jobs/details/job-card.tsx +++ b/frontend/src/components/jobs/details/job-card.tsx @@ -1,103 +1,54 @@ // frontend/src/components/jobs/details/job-card.tsx -import { Badge, Image } from "@mantine/core"; +import { Badge, Box, Image } from "@mantine/core"; import { Job } from "@/types/job"; import { IconMapPin } from "@tabler/icons-react"; +import { formatCapString, getTimeAgo } from "@/lib/utils"; interface JobCardProps { job: Job; isSelected?: boolean; } -function truncateText(text: string, maxLength: number) { - if (!text) return ""; - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; -} - -function getTimeAgo(dateString: string) { - const date = new Date(dateString); - const now = new Date(); - const diffTime = Math.abs(now.getTime() - date.getTime()); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - return `${diffDays}d ago`; -} - export default function JobCard({ job, isSelected }: JobCardProps) { return ( -
- {/* Header Section */} -
- {/* Logo and Title Section */} -
+
+
{job.company.name} - -
-

{job.title}

-

{job.company.name}

-
- -

{job.locations[0]}

-
+
+ + {job.title} + + {job.company.name} + + + {job.locations[0]} +
- - {/* Posted Time */} - - {getTimeAgo(job.updated_at)} - + {getTimeAgo(job.updated_at)}
- - {/* Description Section */} -

- {truncateText(job.description || "", 150)} -

- - {/* Tags Section */} -
+
{job.description}
+
{job.type && ( - - {job.type} - - )} - {job.working_rights?.[0] && ( - - {job.working_rights[0] === "VISA_SPONSORED" - ? "Visa-Friendly" - : "Citizen/PR"} - - )} - {job.industry && ( - - {job.industry} + + {formatCapString(job.type)} )}
-
+ ); } diff --git a/frontend/src/components/layout/nav-bar.tsx b/frontend/src/components/layout/nav-bar.tsx index 9cec09a..3d4612b 100644 --- a/frontend/src/components/layout/nav-bar.tsx +++ b/frontend/src/components/layout/nav-bar.tsx @@ -1,12 +1,17 @@ +"use client"; + import Logo from "@/components/layout/logo"; -import { Button } from "@mantine/core"; +import { Button, useMantineColorScheme } from "@mantine/core"; import Link from "next/link"; export default function NavBar() { + const { setColorScheme, clearColorScheme } = useMantineColorScheme(); + return ( diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index 045c2f9..9610500 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -1,29 +1,46 @@ -import { createTheme, colorsTuple } from "@mantine/core"; -import resolveConfig from "tailwindcss/resolveConfig"; -import tailwindConfig from "@/../tailwind.config"; -const twConfig = resolveConfig(tailwindConfig); +import { createTheme, colorsTuple, Badge } from "@mantine/core"; const theme = createTheme({ fontFamily: "Poppins", colors: { - primary: colorsTuple(twConfig.theme.colors.primary), - text: colorsTuple(twConfig.theme.colors.text), - background: colorsTuple(twConfig.theme.colors.background), - secondary: colorsTuple(twConfig.theme.colors.secondary), - accent: colorsTuple(twConfig.theme.colors.accent), - dark: [ - "#c9c9c9", - "#b8b8b8", - "#828282", - "#696969", - "#424242", - "#3a3a3a", - "#2e2e2e", - "#242424", + background: [ + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#1f1f1f", "#1f1f1f", - "#141414", + "#1f1f1f", + ], + secondary: [ + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#2e2e2e", + "#2e2e2e", + "#2e2e2e", + ], + selected: [ + "#e8e8e8", + "#e8e8e8", + "#e8e8e8", + "#e8e8e8", + "#e8e8e8", + "#e8e8e8", + "#e8e8e8", + "#3a3a3a", + "#3a3a3a", + "#3a3a3a", ], + accent: colorsTuple("#ffe22f"), }, + primaryColor: "secondary", }); -theme.primaryColor = "secondary"; export { theme }; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 9cf5437..fd5c370 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -76,3 +76,35 @@ export default function serializeJob(job: MongoJob): Job { close_date: serializeDate(job.close_date), }; } + +/** + * Converts a capitalized string with underscores to title case with spaces + * Example: "VISA_SPONSORED" -> "Visa Sponsored" + * Example: "AUSTRALIA" -> "Australia" + * + * @param str - The uppercase string to convert + * @returns A formatted string in title case + */ +export function formatCapString(str: string): string { + console.log("converting ", str); + console.log( + "result", + str + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "), + ); + + return str + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +export function getTimeAgo(dateString: string) { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return `${diffDays}d ago`; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 85bea26..b698839 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -7,18 +7,5 @@ export default { "./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], - theme: { - extend: { - colors: { - text: "var(--text)", - background: "var(--background)", - primary: "var(--primary)", - secondary: "var(--secondary)", - accent: "var(--accent)", - underline: "var(--underline)", - selected: "var(--selected)", - }, - }, - }, plugins: [], } satisfies Config; From 0727ec20146938b0a876f19b5c95cdda81e9c067 Mon Sep 17 00:00:00 2001 From: Ed Date: Tue, 28 Jan 2025 00:12:36 +1100 Subject: [PATCH 04/11] code dump, cleanup later --- frontend/src/app/globals.css | 8 + frontend/src/app/jobs/actions.ts | 20 +- frontend/src/app/jobs/layout.tsx | 2 +- frontend/src/app/jobs/page.tsx | 17 +- frontend/src/app/layout.tsx | 7 +- .../src/components/jobs/details/job-card.tsx | 23 +- .../components/jobs/details/job-details.tsx | 244 ++++++------------ .../jobs/filters/dropdown-filter.tsx | 106 +++++++- .../components/jobs/filters/dropdown-sort.tsx | 3 +- .../jobs/filters/filter-section.tsx | 34 ++- .../src/components/jobs/job-pagination.tsx | 8 +- .../src/components/jobs/search/search-bar.tsx | 11 +- frontend/src/components/layout/logo.tsx | 8 +- frontend/src/components/layout/nav-bar.tsx | 42 +-- .../src/context/filter/filter-context.tsx | 2 + .../src/context/filter/filter-provider.tsx | 4 +- frontend/src/lib/theme.ts | 30 ++- frontend/src/lib/utils.ts | 22 +- frontend/src/types/filters.ts | 1 - 19 files changed, 337 insertions(+), 255 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a554699..ff4b64d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -14,3 +14,11 @@ scrollbar-width: none; /* Firefox */ } } + +.underline-fancy { + text-decoration: underline; + font-weight: bold; + text-decoration-color: var(--mantine-color-accent-0); + text-underline-offset: 4px; + text-decoration-thickness: 2px; +} diff --git a/frontend/src/app/jobs/actions.ts b/frontend/src/app/jobs/actions.ts index ecba895..648361f 100644 --- a/frontend/src/app/jobs/actions.ts +++ b/frontend/src/app/jobs/actions.ts @@ -30,11 +30,25 @@ export async function getJobs( 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: [ diff --git a/frontend/src/app/jobs/layout.tsx b/frontend/src/app/jobs/layout.tsx index 233da44..546ee4a 100644 --- a/frontend/src/app/jobs/layout.tsx +++ b/frontend/src/app/jobs/layout.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren } from "react"; export default function JobsLayout({ children }: PropsWithChildren) { return ( -
{children}
+
{children}
); } diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index 33aca91..51186c2 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -1,5 +1,4 @@ // frontend/src/app/jobs/page.tsx -import { Title } from "@mantine/core"; import SearchBar from "@/components/jobs/search/search-bar"; import FilterSection from "@/components/jobs/filters/filter-section"; import JobList from "@/components/jobs/details/job-list"; @@ -18,15 +17,13 @@ export default async function JobsPage({ const { jobs, total } = await getJobs(await searchParams); return ( -
- - <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> - +
+ + Find Internships and{" "} + Student Jobs + - + }>
@@ -34,7 +31,7 @@ export default async function JobsPage({
- +
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 125f7bf..0c3c2e6 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -12,7 +12,10 @@ import { theme } from "@/lib/theme"; import { Poppins } from "next/font/google"; -const poppins = Poppins({ subsets: ["latin"], weight: ["400", "700"] }); +const poppins = Poppins({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], +}); export default function RootLayout({ children }: PropsWithChildren) { return ( @@ -22,7 +25,7 @@ export default function RootLayout({ children }: PropsWithChildren) { -
+
{children}
diff --git a/frontend/src/components/jobs/details/job-card.tsx b/frontend/src/components/jobs/details/job-card.tsx index c535210..788fc29 100644 --- a/frontend/src/components/jobs/details/job-card.tsx +++ b/frontend/src/components/jobs/details/job-card.tsx @@ -12,11 +12,10 @@ interface JobCardProps { export default function JobCard({ job, isSelected }: JobCardProps) { return ( // Have to use Box here since I can't define bg-[--mantine-color-selected]. - // It requires a shade e.g. bg-[--mantine-color-selected.0], which results - // in breaking changes between light and dark mode. + // Doing so requires a shade which breaks between light and dark mode.
@@ -24,9 +23,7 @@ export default function JobCard({ job, isSelected }: JobCardProps) { {job.company.name}
@@ -35,19 +32,29 @@ export default function JobCard({ job, isSelected }: JobCardProps) { {job.company.name} - {job.locations[0]} + {formatCapString(job.locations[0])}
{getTimeAgo(job.updated_at)}
{job.description}
-
+
{job.type && ( {formatCapString(job.type)} )} + {job.working_rights?.[0] && ( + + {job.working_rights[0] === "VISA_SPONSORED" + ? "Visa-Friendly" + : "Citizen/PR"} + + )} + + Banking +
); diff --git a/frontend/src/components/jobs/details/job-details.tsx b/frontend/src/components/jobs/details/job-details.tsx index 99e5f50..030bbb3 100644 --- a/frontend/src/components/jobs/details/job-details.tsx +++ b/frontend/src/components/jobs/details/job-details.tsx @@ -2,22 +2,18 @@ import { Text, - Title, Badge, - Group, Button, Card, - Stack, Image, Divider, ScrollArea, - Box, - Flex, } from "@mantine/core"; -import { IconMapPin, IconPencil, IconBriefcase2 } from "@tabler/icons-react"; +import { IconMapPin, IconPencil, IconFolderOpen } from "@tabler/icons-react"; import DOMPurify from "isomorphic-dompurify"; import { useFilterContext } from "@/context/filter/filter-context"; -import { useEffect } from "react"; +import { formatCapString } from "@/lib/utils"; +import Link from "next/link"; function formatISODate(isoDate: string): string { const date = new Date(isoDate); @@ -48,169 +44,95 @@ export default function JobDetails() { }; return ( - - ({ - paddingLeft: theme.spacing.md, - paddingRight: theme.spacing.xl, - paddingBottom: theme.spacing.md, - })} - type="hover" - > - {/* Header Section */} - - - {/* Logo and Company Name */} - - {selectedJob.company.name} - - {selectedJob.company.name} - - - - {/* Apply Now Button */} - - - - {/* Job Title */} - {selectedJob.title} - - {/* Job Information section */} - ({ - flexDirection: "row", - [`@media (maxWidth: ${theme.breakpoints.sm}px)`]: { - flexDirection: "column", - alignItems: "center", - }, - })} - > - {/* Locations */} - - - {selectedJob.locations?.map((location) => ( - - {location} - - ))} - ({ - display: "block", - [`@media (maxWidth: ${theme.breakpoints.sm}px)`]: { - display: "none", - }, - })} - /> + + +
+
+ {selectedJob.title} + + {selectedJob.company.name} + - {/* Post Date */} - - Posted {formatISODate(selectedJob.created_at)} - - - - {/* Job Type */} - {selectedJob.type} - - - - {/* Description Section */} - - - - + <IconMapPin size={20} stroke={1.5} /> + {selectedJob.locations?.map((location) => ( + <Badge + key={location} + fw={300} + tt={"none"} + color="dark.4" + size="lg" + radius="lg" + > + {formatCapString(location)} + </Badge> + ))} + <Divider size={2} color="accent" orientation="vertical" /> + <Text size="sm"> + Posted {formatISODate(selectedJob.created_at)} + </Text> + <Divider size={2} color="accent" orientation="vertical" /> + <Text size="sm"> + {selectedJob.type && formatCapString(selectedJob.type)} Role + </Text> + </div> + </div> + <Image + alt={selectedJob.company.name} + src={selectedJob.company.logo} + className={"h-20 w-20 object-contain rounded-md bg-white"} + /> + </div> + + <div className={"flex flex-col space-y-1 mt-8"}> + <div className={"flex items-center mb-2"}> + <IconPencil size={16} stroke={1.5} /> + <span className={"underline-fancy text-lg pl-2"}> Job Description - - - +
+
-
- - - - {/* Working Rights Section */} - - - - + className={"text-sm"} + /> + </div> + + <div className={"flex flex-col space-y-1 mt-2"}> + <div className={"flex items-center mb-2"}> + <IconPencil size={16} stroke={1.5} /> + <span className={"underline-fancy text-lg pl-2 "}> Working Rights - - - + +
+
{selectedJob.working_rights?.map((rights) => ( - - {rights} + + {formatCapString(rights)} ))} - - +
+
+ ); } diff --git a/frontend/src/components/jobs/filters/dropdown-filter.tsx b/frontend/src/components/jobs/filters/dropdown-filter.tsx index 750c74b..5a43f55 100644 --- a/frontend/src/components/jobs/filters/dropdown-filter.tsx +++ b/frontend/src/components/jobs/filters/dropdown-filter.tsx @@ -1,6 +1,18 @@ -import { Select } from "@mantine/core"; +// frontend/src/components/jobs/filters/dropdown-filter.tsx +import { useEffect, useState } from "react"; +import { + Checkbox, + Combobox, + Group, + Input, + Text, + useCombobox, +} from "@mantine/core"; +import { IconChevronDown } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; import { JobFilters } from "@/types/filters"; +import { formatCapString, getPluralLabel } from "@/lib/utils"; +import { useSearchParams } from "next/navigation"; interface DropdownFilterProps { label: string; @@ -13,16 +25,90 @@ export default function DropdownFilter({ filterKey, options, }: DropdownFilterProps) { - const { updateFilters } = useFilterContext(); + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"), + }); + + const { filters, updateFilters } = useFilterContext(); + const [localSelected, setLocalSelected] = useState( + (filters.filters[filterKey] as string[]) || [], + ); + const searchParams = useSearchParams(); + + // Sync with URL on mount + useEffect(() => { + const urlValues = searchParams.getAll(`${filterKey}[]`); + if (urlValues.length > 0) { + setLocalSelected(urlValues); + } + }); + + const handleValueSelect = (value: string) => { + const newValues = localSelected.includes(value) + ? localSelected.filter((item) => item !== value) + : [...localSelected, value]; + + setLocalSelected(newValues); + updateFilters({ + filters: { + ...filters.filters, + [filterKey]: newValues, + page: 1, + }, + }); + }; + + const getDisplayText = () => { + if (localSelected.length === 0) return label; + if (localSelected.length === 1) return formatCapString(localSelected[0]); + return `${localSelected.length} ${getPluralLabel(label)}`; + }; return ( - } + onClick={() => combobox.toggleDropdown()} + className={`h-8 min-w-32`} + > + 0 ? "light" : "dimmed"}> + {getDisplayText()} + + + + + + + {options.map((option) => ( + + + {}} + aria-hidden + tabIndex={-1} + style={{ pointerEvents: "none" }} + /> + {formatCapString(option)} + + + ))} + + + ); } diff --git a/frontend/src/components/jobs/filters/dropdown-sort.tsx b/frontend/src/components/jobs/filters/dropdown-sort.tsx index edc047b..0cd974f 100644 --- a/frontend/src/components/jobs/filters/dropdown-sort.tsx +++ b/frontend/src/components/jobs/filters/dropdown-sort.tsx @@ -13,7 +13,8 @@ export default function DropdownSort() { value={filters.filters.sortBy} allowDeselect={false} placeholder="Sort by" - aria-label="Sort jobs" + radius={"md"} + className="max-w-36" /> ); } diff --git a/frontend/src/components/jobs/filters/filter-section.tsx b/frontend/src/components/jobs/filters/filter-section.tsx index 81cca60..593d8ab 100644 --- a/frontend/src/components/jobs/filters/filter-section.tsx +++ b/frontend/src/components/jobs/filters/filter-section.tsx @@ -3,14 +3,38 @@ import DropdownSort from "@/components/jobs/filters/dropdown-sort"; import { Text } from "@mantine/core"; import { useFilterContext } from "@/context/filter/filter-context"; +import DropdownFilter from "@/components/jobs/filters/dropdown-filter"; +import { INDUSTRY_FIELDS, LOCATIONS, WORKING_RIGHTS } from "@/types/job"; -export default function FilterSection() { - const { filters } = useFilterContext(); +interface FilterSectionProps { + _totalJobs: number; +} + +export default function FilterSection({ _totalJobs }: FilterSectionProps) { + const { totalJobs, setTotalJobs } = useFilterContext(); + + setTotalJobs(_totalJobs); return ( -
- {filters.totalJobs} Results -
+
+ {totalJobs} Results +
+ + + +
); diff --git a/frontend/src/components/jobs/job-pagination.tsx b/frontend/src/components/jobs/job-pagination.tsx index 96df8c6..7a4ff7a 100644 --- a/frontend/src/components/jobs/job-pagination.tsx +++ b/frontend/src/components/jobs/job-pagination.tsx @@ -5,15 +5,11 @@ import { Pagination } from "@mantine/core"; import { useFilterContext } from "@/context/filter/filter-context"; interface JobPaginationProps { - totalJobs: number; pageSize?: number; } -export default function JobPagination({ - totalJobs, - pageSize = 20, -}: JobPaginationProps) { - const { filters, updateFilters } = useFilterContext(); +export default function JobPagination({ pageSize = 20 }: JobPaginationProps) { + const { filters, updateFilters, totalJobs } = useFilterContext(); const totalPages = Math.ceil(totalJobs / pageSize); if (totalPages <= 1) return null; diff --git a/frontend/src/components/jobs/search/search-bar.tsx b/frontend/src/components/jobs/search/search-bar.tsx index 46897f5..8962756 100644 --- a/frontend/src/components/jobs/search/search-bar.tsx +++ b/frontend/src/components/jobs/search/search-bar.tsx @@ -20,7 +20,12 @@ export default function SearchBar() { return ( } + leftSection={ + + } onChange={(e) => handleSearch(e.currentTarget.value)} rightSection={ } + radius="lg" variant="filled" + className={"mt-4"} styles={{ input: { - borderRadius: "12px", padding: "28px", paddingLeft: "40px", - background: "var(--secondary)", }, }} /> diff --git a/frontend/src/components/layout/logo.tsx b/frontend/src/components/layout/logo.tsx index 2454511..43089fa 100644 --- a/frontend/src/components/layout/logo.tsx +++ b/frontend/src/components/layout/logo.tsx @@ -1,14 +1,10 @@ -import { Text, Divider } from "@mantine/core"; import MacLogo from "@/assets/mac.svg"; import Image from "next/image"; export default function Logo() { return (
- Follow us on Twitter - - - JOB BOARD - + MAC Logo + | Jobs
); } diff --git a/frontend/src/components/layout/nav-bar.tsx b/frontend/src/components/layout/nav-bar.tsx index 3d4612b..e88d0e6 100644 --- a/frontend/src/components/layout/nav-bar.tsx +++ b/frontend/src/components/layout/nav-bar.tsx @@ -1,32 +1,40 @@ "use client"; import Logo from "@/components/layout/logo"; -import { Button, useMantineColorScheme } from "@mantine/core"; +import { ActionIcon, useMantineColorScheme } from "@mantine/core"; import Link from "next/link"; +import { IconMoon, IconSun } from "@tabler/icons-react"; export default function NavBar() { - const { setColorScheme, clearColorScheme } = useMantineColorScheme(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); return ( - From d463d1c14fac9332340df931b58bb1d050e5ae9a Mon Sep 17 00:00:00 2001 From: Ed Date: Tue, 28 Jan 2025 23:14:12 +1100 Subject: [PATCH 09/11] refactoring to improve code clarity --- frontend/src/app/jobs/actions.ts | 16 +++ frontend/src/app/jobs/loading.tsx | 34 +---- frontend/src/app/jobs/page.tsx | 9 +- .../src/components/jobs/details/job-card.tsx | 27 ++-- .../jobs/details/job-description.tsx | 21 +++ .../components/jobs/details/job-details.tsx | 123 +++--------------- .../components/jobs/details/job-header.tsx | 40 ++++++ .../jobs/details/job-working-rights.tsx | 22 ++++ .../jobs/filters/dropdown-filter.tsx | 1 + .../jobs/filters/filter-section.tsx | 1 - .../src/components/jobs/search/search-bar.tsx | 8 +- .../src/components/layout/heading-text.tsx | 8 ++ frontend/src/components/ui/badge.tsx | 21 +++ .../src/components/ui/section-heading.tsx | 15 +++ frontend/src/lib/utils.ts | 31 ++++- 15 files changed, 210 insertions(+), 167 deletions(-) create mode 100644 frontend/src/components/jobs/details/job-description.tsx create mode 100644 frontend/src/components/jobs/details/job-header.tsx create mode 100644 frontend/src/components/jobs/details/job-working-rights.tsx create mode 100644 frontend/src/components/layout/heading-text.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/section-heading.tsx diff --git a/frontend/src/app/jobs/actions.ts b/frontend/src/app/jobs/actions.ts index 648361f..50842e0 100644 --- a/frontend/src/app/jobs/actions.ts +++ b/frontend/src/app/jobs/actions.ts @@ -12,6 +12,22 @@ export interface MongoJob extends Omit { _id: ObjectId; } +/** + * 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, ): Promise<{ jobs: Job[]; total: number }> { diff --git a/frontend/src/app/jobs/loading.tsx b/frontend/src/app/jobs/loading.tsx index 1ae84d7..755216e 100644 --- a/frontend/src/app/jobs/loading.tsx +++ b/frontend/src/app/jobs/loading.tsx @@ -1,36 +1,4 @@ // frontend/src/app/jobs/loading.tsx export default function JobLoading() { - return ( -
- {/* Header skeleton */} -
- - {/* Search bar skeleton */} -
- - {/* Filter section skeleton */} -
-
-
-
- - {/* Job list and details skeleton */} -
-
-
- {[...Array(5)].map((_, i) => ( -
- ))} -
-
- -
-
-
-
-
- ); + return
Loading...
; } diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index ef88859..03c5247 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -8,20 +8,21 @@ 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, }: { searchParams: Promise>; }) { + // 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, total } = await getJobs(await searchParams); return (
- - Find Internships and{" "} - Student Jobs - + diff --git a/frontend/src/components/jobs/details/job-card.tsx b/frontend/src/components/jobs/details/job-card.tsx index 788fc29..d30329f 100644 --- a/frontend/src/components/jobs/details/job-card.tsx +++ b/frontend/src/components/jobs/details/job-card.tsx @@ -1,8 +1,9 @@ // frontend/src/components/jobs/details/job-card.tsx -import { Badge, Box, Image } from "@mantine/core"; +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"; interface JobCardProps { job: Job; @@ -11,8 +12,6 @@ interface JobCardProps { export default function JobCard({ job, isSelected }: JobCardProps) { return ( - // Have to use Box here since I can't define bg-[--mantine-color-selected]. - // Doing so requires a shade which breaks between light and dark mode.
{job.description}
- {job.type && ( - - {formatCapString(job.type)} - - )} + {job.type && } {job.working_rights?.[0] && ( - - {job.working_rights[0] === "VISA_SPONSORED" - ? "Visa-Friendly" - : "Citizen/PR"} - + )} - - Banking - +
); diff --git a/frontend/src/components/jobs/details/job-description.tsx b/frontend/src/components/jobs/details/job-description.tsx new file mode 100644 index 0000000..33cfe6b --- /dev/null +++ b/frontend/src/components/jobs/details/job-description.tsx @@ -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 ( +
+ +
+
+ ); +} diff --git a/frontend/src/components/jobs/details/job-details.tsx b/frontend/src/components/jobs/details/job-details.tsx index bfa617d..aa0a680 100644 --- a/frontend/src/components/jobs/details/job-details.tsx +++ b/frontend/src/components/jobs/details/job-details.tsx @@ -1,36 +1,16 @@ +// frontend/src/components/jobs/details/job-details.tsx "use client"; -import { - Text, - Badge, - Button, - Card, - Image, - Divider, - ScrollArea, -} from "@mantine/core"; -import { IconMapPin, IconPencil, IconFolderOpen } from "@tabler/icons-react"; -import DOMPurify from "isomorphic-dompurify"; +import { Button, Card, ScrollArea } from "@mantine/core"; +import { IconFolderOpen } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; -import { formatCapString } from "@/lib/utils"; -import Link from "next/link"; - -function formatISODate(isoDate: string): string { - const date = new Date(isoDate); - - const day = date.getDate(); - const month = date.toLocaleString("en-US", { month: "short" }); - const year = date.getFullYear(); - - return `${day} ${month} ${year}`; -} - -function sanitizeHtml(html: string) { - return DOMPurify.sanitize(html); -} +import JobDescription from "@/components/jobs/details/job-description"; +import JobWorkingRights from "@/components/jobs/details/job-working-rights"; +import JobHeader from "@/components/jobs/details/job-header"; export default function JobDetails() { const { selectedJob, isLoading } = useFilterContext(); + if (!selectedJob || isLoading) { return (
@@ -40,96 +20,23 @@ export default function JobDetails() { } const handleApplyClick = () => { - window.open(selectedJob.application_url, "_blank"); // Open link in a new tab + window.open(selectedJob.application_url, "_blank"); }; return ( -
-
- {selectedJob.title} - - {selectedJob.company.name} - - -
- - {selectedJob.locations?.map((location) => ( - - {formatCapString(location)} - - ))} - - - Posted {formatISODate(selectedJob.created_at)} - - - - {selectedJob.type && formatCapString(selectedJob.type)} Role - -
-
- {selectedJob.company.name} -
- -
-
- - - Job Description - -
-
-
- -
-
- - - Working Rights - -
-
- {selectedJob.working_rights?.map((rights) => ( - - {formatCapString(rights)} - - ))} -
-
+ + + + diff --git a/frontend/src/components/jobs/details/job-header.tsx b/frontend/src/components/jobs/details/job-header.tsx new file mode 100644 index 0000000..b37e8af --- /dev/null +++ b/frontend/src/components/jobs/details/job-header.tsx @@ -0,0 +1,40 @@ +// frontend/src/components/jobs/details/sections/job-header.tsx +import { Image, Divider, Text } from "@mantine/core"; +import { IconMapPin } from "@tabler/icons-react"; +import { Job } from "@/types/job"; +import { formatCapString, formatISODate } from "@/lib/utils"; +import Link from "next/link"; +import Badge from "@/components/ui/badge"; + +interface JobHeaderProps { + job: Job; +} + +export default function JobHeader({ job }: JobHeaderProps) { + return ( +
+
+ {job.title} + + {job.company.name} + + +
+ + {job.locations?.map((location) => ( + + ))} + + {formatISODate(job.created_at)} + + {job.type && formatCapString(job.type)} Role +
+
+ {job.company.name} +
+ ); +} diff --git a/frontend/src/components/jobs/details/job-working-rights.tsx b/frontend/src/components/jobs/details/job-working-rights.tsx new file mode 100644 index 0000000..bf28eca --- /dev/null +++ b/frontend/src/components/jobs/details/job-working-rights.tsx @@ -0,0 +1,22 @@ +// frontend/src/components/jobs/details/sections/job-working-rights.tsx +import SectionHeading from "@/components/ui/section-heading"; +import Badge from "@/components/ui/badge"; +import { formatCapString } from "@/lib/utils"; +import { WorkingRight } from "@/types/job"; + +interface JobWorkingRightsProps { + rights: WorkingRight[]; +} + +export default function JobWorkingRights({ rights }: JobWorkingRightsProps) { + return ( +
+ +
+ {rights?.map((right) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/jobs/filters/dropdown-filter.tsx b/frontend/src/components/jobs/filters/dropdown-filter.tsx index 5a43f55..3b96bbc 100644 --- a/frontend/src/components/jobs/filters/dropdown-filter.tsx +++ b/frontend/src/components/jobs/filters/dropdown-filter.tsx @@ -44,6 +44,7 @@ export default function DropdownFilter({ } }); + // Updates locally selected value & filters const handleValueSelect = (value: string) => { const newValues = localSelected.includes(value) ? localSelected.filter((item) => item !== value) diff --git a/frontend/src/components/jobs/filters/filter-section.tsx b/frontend/src/components/jobs/filters/filter-section.tsx index 8533beb..12baa7b 100644 --- a/frontend/src/components/jobs/filters/filter-section.tsx +++ b/frontend/src/components/jobs/filters/filter-section.tsx @@ -1,6 +1,5 @@ "use client"; -import DropdownSort from "@/components/jobs/filters/dropdown-sort"; import { Text } from "@mantine/core"; import { useFilterContext } from "@/context/filter/filter-context"; import DropdownFilter from "@/components/jobs/filters/dropdown-filter"; diff --git a/frontend/src/components/jobs/search/search-bar.tsx b/frontend/src/components/jobs/search/search-bar.tsx index 8962756..baa157c 100644 --- a/frontend/src/components/jobs/search/search-bar.tsx +++ b/frontend/src/components/jobs/search/search-bar.tsx @@ -12,30 +12,30 @@ export default function SearchBar() { filters: { ...filters.filters, search: value, - page: 1, // Reset to first page on new search + page: 1, }, }); }, 300); return ( } - onChange={(e) => handleSearch(e.currentTarget.value)} rightSection={ } + placeholder="Search for a company or a role..." + onChange={(e) => handleSearch(e.currentTarget.value)} radius="lg" variant="filled" - className={"mt-4"} + className="mt-4" styles={{ input: { padding: "28px", diff --git a/frontend/src/components/layout/heading-text.tsx b/frontend/src/components/layout/heading-text.tsx new file mode 100644 index 0000000..2369850 --- /dev/null +++ b/frontend/src/components/layout/heading-text.tsx @@ -0,0 +1,8 @@ +export default function HeadingText() { + return ( + + Find Internships and{" "} + Student Jobs + + ); +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..9b0145f --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,21 @@ +// frontend/src/components/ui/badge.tsx +import { Badge as MantineBadge } from "@mantine/core"; + +interface BadgeProps { + text: string; + size?: "sm" | "lg"; +} + +export default function Badge({ text, size = "sm" }: BadgeProps) { + return ( + + {text} + + ); +} diff --git a/frontend/src/components/ui/section-heading.tsx b/frontend/src/components/ui/section-heading.tsx new file mode 100644 index 0000000..17cbd36 --- /dev/null +++ b/frontend/src/components/ui/section-heading.tsx @@ -0,0 +1,15 @@ +// frontend/src/components/ui/section-heading.tsx +import { IconPencil } from "@tabler/icons-react"; + +interface SectionHeadingProps { + title: string; +} + +export default function SectionHeading({ title }: SectionHeadingProps) { + return ( +
+ + {title} +
+ ); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index c99d4d1..c6a18a5 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -78,6 +78,20 @@ export default function serializeJob(job: MongoJob): Job { }; } +const UPPERCASE_WORDS = new Set([ + "VIC", + "NSW", + "QLD", + "WA", + "NT", + "SA", + "ACT", + "TAS", + "PR", + "NZ", + "AUS", +]); + /** * Converts a capitalized string with underscores to title case with spaces * Example: "VISA_SPONSORED" -> "Visa Sponsored" @@ -89,7 +103,14 @@ export default function serializeJob(job: MongoJob): Job { export function formatCapString(str: string): string { return str .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .map((word) => { + // Check if word should remain uppercase + if (UPPERCASE_WORDS.has(word)) { + return word; + } + // Convert other words to title case + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) .join(" "); } @@ -108,3 +129,11 @@ export function getPluralLabel(label: string) { }; return irregularPlurals[label] || `${label}s`; } + +export function formatISODate(isoDate: string): string { + const date = new Date(isoDate); + const day = date.getDate(); + const month = date.toLocaleString("en-US", { month: "short" }); + const year = date.getFullYear(); + return `${day} ${month} ${year}`; +} From 3ae642cc32e5a4b60533e41c95d92dd5c766c297 Mon Sep 17 00:00:00 2001 From: Ed Date: Tue, 28 Jan 2025 23:16:43 +1100 Subject: [PATCH 10/11] fix eslint warnings --- frontend/src/components/jobs/details/job-list.tsx | 1 + frontend/src/components/jobs/filters/dropdown-filter.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/jobs/details/job-list.tsx b/frontend/src/components/jobs/details/job-list.tsx index 7e559c2..e2d9d91 100644 --- a/frontend/src/components/jobs/details/job-list.tsx +++ b/frontend/src/components/jobs/details/job-list.tsx @@ -18,6 +18,7 @@ export default function JobList({ jobs }: JobListProps) { if (jobs.length > 0 && !selectedJob) { setSelectedJob(jobs[0]); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobs]); if (isLoading) { diff --git a/frontend/src/components/jobs/filters/dropdown-filter.tsx b/frontend/src/components/jobs/filters/dropdown-filter.tsx index 3b96bbc..b90059c 100644 --- a/frontend/src/components/jobs/filters/dropdown-filter.tsx +++ b/frontend/src/components/jobs/filters/dropdown-filter.tsx @@ -42,7 +42,7 @@ export default function DropdownFilter({ if (urlValues.length > 0) { setLocalSelected(urlValues); } - }); + }, [searchParams, filterKey]); // Updates locally selected value & filters const handleValueSelect = (value: string) => { From 87a5d78311a05a3f3893585bf0071db5a8dd3fd3 Mon Sep 17 00:00:00 2001 From: Ed Date: Tue, 28 Jan 2025 23:22:14 +1100 Subject: [PATCH 11/11] revert some changes --- frontend/src/app/globals.css | 15 +++++++++++++++ frontend/src/app/jobs/loading.tsx | 2 +- frontend/src/app/loading.tsx | 15 +-------------- frontend/tailwind.config.ts | 10 ++++++++++ 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ff4b64d..3a784e3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -15,6 +15,21 @@ } } +@layer base { + :root { + --selected: #3a3a3a; + --background: #1f1f1f; + --secondary: #2e2e2e; + --accent: #ffe22f; + } + .dark { + --selected: #3a3a3a; + --background: #1f1f1f; + --secondary: #2e2e2e; + --accent: #ffe22f; + } +} + .underline-fancy { text-decoration: underline; font-weight: bold; diff --git a/frontend/src/app/jobs/loading.tsx b/frontend/src/app/jobs/loading.tsx index 755216e..6616fa2 100644 --- a/frontend/src/app/jobs/loading.tsx +++ b/frontend/src/app/jobs/loading.tsx @@ -1,4 +1,4 @@ // frontend/src/app/jobs/loading.tsx export default function JobLoading() { - return
Loading...
; + return
Loading Job...
; } diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx index 4d593e6..fc80ef0 100644 --- a/frontend/src/app/loading.tsx +++ b/frontend/src/app/loading.tsx @@ -1,16 +1,3 @@ export default function Loading() { - return ( -
-
-
- {[...Array(5)].map((_, i) => ( -
- ))} -
-
-
- ); + return
Loading...
; } diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index b698839..470d185 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -8,4 +8,14 @@ export default { "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], plugins: [], + theme: { + extend: { + colors: { + selected: "var(--selected)", + background: "var(--background)", + secondary: "var(--secondary)", + accent: "var(--accent)", + }, + }, + }, } satisfies Config;