diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c25d7e9..87149da 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "dompurify": "^3.2.3", + "framer-motion": "^12.38.0", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", @@ -840,9 +841,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -859,9 +857,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -878,9 +873,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -897,9 +889,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -916,9 +905,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -935,9 +921,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -954,9 +937,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -973,9 +953,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -992,9 +969,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1017,9 +991,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1042,9 +1013,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1067,9 +1035,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1092,9 +1057,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1117,9 +1079,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1142,9 +1101,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1167,9 +1123,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1441,9 +1394,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1460,9 +1410,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1479,9 +1426,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1498,9 +1442,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3977,6 +3918,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5330,6 +5298,21 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f07d174..9cfee00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "dompurify": "^3.2.3", + "framer-motion": "^12.38.0", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", diff --git a/frontend/src/app/jobs/loading.tsx b/frontend/src/app/jobs/loading.tsx index a2688d4..483ca23 100644 --- a/frontend/src/app/jobs/loading.tsx +++ b/frontend/src/app/jobs/loading.tsx @@ -4,11 +4,14 @@ import MainContentLoading from "@/components/layout/main-content-loading"; export default function Loading() { return (
- {/* FilterSection placeholder - this should always be visible */} -
-
-
-
+ {/* Filter bar placeholder */} +
+
+
+
+
+
+
diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index b89af99..f5f6afa 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -1,13 +1,9 @@ // frontend/src/app/jobs/page.tsx import FilterSection from "@/components/filters/filter-section"; -import JobList from "@/components/jobs/job-list"; -import JobDetails from "@/components/jobs/job-details"; +import JobsContent from "@/components/jobs/jobs-content"; import { JobFilters } from "@/types/filters"; import { getJobs } from "@/actions/jobs.fetch"; import NoResults from "@/components/ui/no-results"; -import { Suspense } from "react"; -import JobListLoading from "@/components/layout/job-list-loading"; -import JobDetailsLoading from "@/components/layout/job-details-loading"; export const metadata = { title: "Jobs", @@ -20,13 +16,7 @@ export default async function JobsPage({ }: { 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 filters = await searchParams; - - // Fetch regular jobs with pagination. const { jobs, total } = await getJobs(filters); return ( @@ -36,18 +26,8 @@ export default async function JobsPage({ {total <= 0 ? ( ) : ( -
-
- }> - - -
- -
- }> - - -
+
+
)} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index dbaf664..9f571c9 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,6 +9,9 @@ import { SpeedInsights } from "@vercel/speed-insights/next"; import { GoogleAnalytics } from "@next/third-parties/google"; import NavBar from "@/components/layout/nav-bar"; +import DotBackground from "@/components/ui/dot-background"; +import { InitialLoad } from "@/components/layout/initial-load"; +import { PageTransition } from "@/components/layout/page-transition"; import { MantineProvider } from "@mantine/core"; import { ColorSchemeScript } from "@mantine/core"; import { PropsWithChildren, Suspense } from "react"; @@ -56,16 +59,21 @@ export default function RootLayout({ children }: PropsWithChildren) {
+ - -
- {children} + + +
+ + {children} +
+
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 541f178..34ed6ab 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,7 +4,6 @@ import { Button } from "@mantine/core"; import { useEffect } from "react"; import Link from "next/link"; import { IconArrowRight } from "@tabler/icons-react"; -import DotBackground from "@/components/ui/dot-background"; import { useFilterContext } from "@/context/filter/filter-context"; export default function Page() { const { filters, updateFilters } = useFilterContext(); @@ -35,8 +34,6 @@ export default function Page() { return ( <> - -
{ + const currentValues = selectedValues; + const newValues = currentValues.includes(value) + ? currentValues.filter((v) => v !== value) + : [...currentValues, value]; + + updateFilters({ + filters: { + ...filters.filters, + [filterKey]: newValues, + page: 1, + }, + }); + }; + + const isActive = selectedValues.length > 0; + + return ( + + + + + + + {options.map((option) => { + const isSelected = selectedValues.includes(option); + return ( + handleToggle(option)} + bg={isSelected ? "rgba(255,226,47,0.1)" : "transparent"} + c={isSelected ? "accent" : "white"} + className="transition-colors duration-100" + > +
+
+ {isSelected && ( + + + + )} +
+ {formatCapString(option)} +
+
+ ); + })} +
+
+ ); +} + +function ActiveFilterChips() { + const { filters, updateFilters } = useFilterContext(); + const { jobTypes, locations, workingRights, industryFields } = + filters.filters; + + const allActive: { key: string; value: string; label: string }[] = [ + ...jobTypes.map((v) => ({ + key: "jobTypes", + value: v, + label: formatCapString(v), + })), + ...locations.map((v) => ({ + key: "locations", + value: v, + label: formatCapString(v), + })), + ...workingRights.map((v) => ({ + key: "workingRights", + value: v, + label: formatCapString(v), + })), + ...industryFields.map((v) => ({ + key: "industryFields", + value: v, + label: formatCapString(v), + })), + ]; + + if (allActive.length === 0) return null; + + const removeFilter = (key: string, value: string) => { + const currentValues = filters.filters[ + key as keyof typeof filters.filters + ] as string[]; + updateFilters({ + filters: { + ...filters.filters, + [key]: currentValues.filter((v) => v !== value), + page: 1, + }, + }); + }; + + return ( +
+ {allActive.map((chip) => ( + + ))} +
+ ); +} + +const viewModeIcons: { mode: ViewMode; icon: typeof IconLayoutColumns; label: string }[] = [ + { mode: "split", icon: IconLayoutColumns, label: "Split view" }, + { mode: "grid", icon: IconLayoutGrid, label: "Grid view" }, + { mode: "dense", icon: IconLayoutList, label: "List view" }, +]; + export default function FilterSection({ _totalJobs }: FilterSectionProps) { - const { totalJobs, setTotalJobs, isLoading } = useFilterContext(); + const { totalJobs, setTotalJobs, isLoading, filters, clearFilters, viewMode, setViewMode } = + useFilterContext(); useEffect(() => { setTotalJobs(_totalJobs); }, [_totalJobs, setTotalJobs]); + const hasActiveFilters = () => { + const { search, industryFields, jobTypes, locations, workingRights } = + filters.filters; + return ( + search !== "" || + industryFields.length > 0 || + jobTypes.length > 0 || + locations.length > 0 || + workingRights.length > 0 + ); + }; + return ( -
- - {isLoading ? "" : totalJobs + " Results"} - - -
- - +
+ {/* Top row: result count + filter dropdowns + view switcher */} +
+ + {`${totalJobs.toLocaleString()} jobs`} + + +
+ + + + + + {hasActiveFilters() && ( + + )} +
+ + {/* View mode switcher */} +
+ {viewModeIcons.map(({ mode, icon: Icon, label }) => ( + + setViewMode(mode)} + radius="md" + className="transition-all duration-150" + > + + + + ))} +
+ + {/* Active filter chips */} +
); } diff --git a/frontend/src/components/jobs/job-card-dense.tsx b/frontend/src/components/jobs/job-card-dense.tsx new file mode 100644 index 0000000..89bb688 --- /dev/null +++ b/frontend/src/components/jobs/job-card-dense.tsx @@ -0,0 +1,65 @@ +// frontend/src/components/jobs/job-card-dense.tsx +import { Job } from "@/types/job"; +import { formatCapString, getTimeAgo } from "@/lib/utils"; +import CompanyLogo from "@/components/jobs/company-logo"; +import { IconChevronRight } from "@tabler/icons-react"; + +interface JobCardDenseProps { + job: Job; + isSelected?: boolean; +} + +export default function JobCardDense({ job, isSelected }: JobCardDenseProps) { + return ( +
+ {/* Yellow accent stripe */} +
+ + + +
+
+ {job.title} +
+
+ {job.company.name} + {job.locations && job.locations.length > 0 && ( + + {" "} + ·{" "} + {job.locations + .slice(0, 2) + .map((loc) => formatCapString(loc)) + .join(", ")} + + )} + {job.type && · {formatCapString(job.type)}} +
+
+ +
+ + {getTimeAgo(job.created_at)} + + +
+
+ ); +} diff --git a/frontend/src/components/jobs/job-card-grid.tsx b/frontend/src/components/jobs/job-card-grid.tsx new file mode 100644 index 0000000..8a24276 --- /dev/null +++ b/frontend/src/components/jobs/job-card-grid.tsx @@ -0,0 +1,60 @@ +// frontend/src/components/jobs/job-card-grid.tsx +import { Job } from "@/types/job"; +import { formatCapString, getTimeAgo } from "@/lib/utils"; +import Badge from "@/components/ui/badge"; +import CompanyLogo from "@/components/jobs/company-logo"; + +interface JobCardGridProps { + job: Job; + isSponsor?: boolean; +} + +export default function JobCardGrid({ job, isSponsor }: JobCardGridProps) { + return ( +
+
+
+ +
+ + {job.title} + + + {job.company.name} + +
+
+
+ {job.locations + ?.slice(0, 2) + .map((loc) => formatCapString(loc)) + .join(", ")}{" "} + · {getTimeAgo(job.created_at)} +
+
+ +
+ {isSponsor && } + {job.type && } + {job.working_rights?.[0] && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/jobs/job-card.tsx b/frontend/src/components/jobs/job-card.tsx index 5490f90..33f4d52 100644 --- a/frontend/src/components/jobs/job-card.tsx +++ b/frontend/src/components/jobs/job-card.tsx @@ -1,5 +1,4 @@ -// frontend/src/components/jobs/details/job-card.tsx -import { Box } from "@mantine/core"; +// frontend/src/components/jobs/job-card.tsx import { Job } from "@/types/job"; import { formatCapString, getTimeAgo } from "@/lib/utils"; import Badge from "@/components/ui/badge"; @@ -11,6 +10,7 @@ interface JobCardProps { isSelected?: boolean; isSponsor?: boolean; } + const removeImageTags = (content: string): string => { return content.replace(/]*>/g, ""); }; @@ -18,50 +18,59 @@ const removeImageTags = (content: string): string => { export default function JobCard({ job, isSelected, isSponsor }: JobCardProps) { const washedDescription = job.one_liner ? removeImageTags(job.one_liner) : ""; return ( - + {/* Yellow accent stripe on selected */} +
+ {/* Top section - company info */}
-
-
+
+
-
- +
+ {job.title} - + {job.company.name}
- + {getTimeAgo(job.created_at)}
-
+ + {washedDescription && ( +
+ )}
{/* Bottom section - badges */} -
- {/* Show a yellow "Sponsored" badge if this is a sponsor card */} - {isSponsor && } +
+ {isSponsor && } {job.type && } {job.working_rights?.[0] && ( 0 && ( formatCapString(loc)) @@ -85,6 +94,6 @@ export default function JobCard({ job, isSelected, isSponsor }: JobCardProps) { /> )}
- +
); } diff --git a/frontend/src/components/jobs/job-description.tsx b/frontend/src/components/jobs/job-description.tsx index b30c38d..6cb0f32 100644 --- a/frontend/src/components/jobs/job-description.tsx +++ b/frontend/src/components/jobs/job-description.tsx @@ -1,5 +1,4 @@ -// frontend/src/components/jobs/details/sections/job-description.tsx -import SectionHeading from "@/components/ui/section-heading"; +// frontend/src/components/jobs/job-description.tsx import { TypographyStylesProvider } from "@mantine/core"; import DOMPurify from "isomorphic-dompurify"; import { IconBook } from "@tabler/icons-react"; @@ -10,17 +9,23 @@ interface JobDescriptionProps { export default function JobDescription({ description }: JobDescriptionProps) { return ( -
- } - /> +
+
+ + + Job Description + +
diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 55aaa78..0522a6e 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -1,7 +1,7 @@ // frontend/src/components/jobs/job-details.tsx "use client"; import { useEffect, useRef, useState } from "react"; -import { ActionIcon, Button, Card, ScrollArea } from "@mantine/core"; +import { Button, ScrollArea } from "@mantine/core"; import { IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; import JobDescription from "@/components/jobs/job-description"; @@ -18,7 +18,7 @@ export default function JobDetails() { // Scroll to top whenever a new job is selected useEffect(() => { if (scrollRef.current) { - scrollRef.current.scrollTo({ top: 0 }); + scrollRef.current.scrollTo({ top: 0, behavior: "smooth" }); } }, [selectedJob]); @@ -30,7 +30,7 @@ export default function JobDetails() { }; }, []); - if (!selectedJob || isLoading) { + if (!selectedJob) { return ; } @@ -46,64 +46,72 @@ export default function JobDetails() { setIsCopied(true); - // Reset copied state after 2 seconds if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { setIsCopied(false); - }, 500); + }, 1500); }; return ( - +
- - {selectedJob && selectedJob.one_liner && ( - - )} - {selectedJob && selectedJob.description && ( - - )} +
+ + {selectedJob.one_liner && ( + + )} + {selectedJob.description && ( + + )} +
-
+ {/* Sticky footer */} +
- - {isCopied ? : } - +
- +
); } diff --git a/frontend/src/components/jobs/job-header.tsx b/frontend/src/components/jobs/job-header.tsx index 1ca1449..985fa14 100644 --- a/frontend/src/components/jobs/job-header.tsx +++ b/frontend/src/components/jobs/job-header.tsx @@ -10,78 +10,85 @@ import { Job } from "@/types/job"; import { formatCapString, formatWorkingRights, getTimeAgo } from "@/lib/utils"; import Link from "next/link"; import CompanyLogo from "@/components/jobs/company-logo"; -import { InfoTag } from "@/components/jobs/info-tag"; interface JobHeaderProps { job: Job; } +function DetailTag({ + icon, + text, +}: { + icon: React.ReactNode; + text: string; +}) { + return ( +
+ {icon} + {text} +
+ ); +} + export default function JobHeader({ job }: JobHeaderProps) { return ( -
-
- {/* Title with responsive sizing and padding */} - +
+
+

{job.title} - +

- {/* Company name link */} {job.company.name} - {/* Info tags flexbox container */} -
- {/* Location */} - } - text={job.locations - ?.map((location) => formatCapString(location)) - .join(", ")} - /> +
+ {job.locations && job.locations.length > 0 && ( + } + text={job.locations + .map((location) => formatCapString(location)) + .join(", ")} + /> + )} - {/* Date found */} - } + } text={`Found ${getTimeAgo(job.created_at)}`} /> - {/* Role type */} {job.type && ( - } - text={`${formatCapString(job.type)} Role`} + } + text={`${formatCapString(job.type)}`} /> )} - {/* Industry field */} {job.industry_field && ( - } + } text={formatCapString(job.industry_field)} /> )} - {/* Working Rights */} - {job.working_rights && ( - } + {job.working_rights && job.working_rights.length > 0 && ( + } text={formatWorkingRights(job.working_rights)} /> )}
- {/* Company logo with responsive sizing */}
); diff --git a/frontend/src/components/jobs/job-list.tsx b/frontend/src/components/jobs/job-list.tsx index 3871b56..c689389 100644 --- a/frontend/src/components/jobs/job-list.tsx +++ b/frontend/src/components/jobs/job-list.tsx @@ -2,6 +2,8 @@ "use client"; import JobCard from "@/components/jobs/job-card"; +import JobCardGrid from "@/components/jobs/job-card-grid"; +import JobCardDense from "@/components/jobs/job-card-dense"; import { useFilterContext } from "@/context/filter/filter-context"; import { Job } from "@/types/job"; import { useEffect, useState } from "react"; @@ -12,12 +14,12 @@ import JobPagination from "@/components/jobs/job-pagination"; import { useMediaQuery } from "@mantine/hooks"; interface JobListProps { - jobs: Job[]; // Regular jobs + jobs: Job[]; } export default function JobList({ jobs }: JobListProps) { - //export default function JobList({ jobs, sponsoredJobs }: JobListProps) { - const { selectedJob, setSelectedJob, isLoading } = useFilterContext(); + const { selectedJob, setSelectedJob, isLoading, viewMode } = + useFilterContext(); const [isModalOpen, setIsModalOpen] = useState(false); const isDesktop = useMediaQuery("(min-width: 1024px)"); @@ -27,13 +29,77 @@ export default function JobList({ jobs }: JobListProps) { } }, [jobs, selectedJob, setSelectedJob]); - if (isLoading) return ; + const handleJobClick = (job: Job) => { + setSelectedJob(job); + // Open modal on mobile, or in grid mode on desktop + if (!isDesktop || viewMode === "grid") { + setIsModalOpen(true); + } + }; + + const renderSplitView = () => ( +
+ {jobs.map((job) => ( +
handleJobClick(job)} + className="cursor-pointer" + > + +
+ ))} +
+ ); + + const renderGridView = () => ( +
+ {jobs.map((job) => ( +
handleJobClick(job)} + > + +
+ ))} +
+ ); + + const renderDenseView = () => ( +
+ {jobs.map((job) => ( +
handleJobClick(job)} + className="cursor-pointer" + > + +
+ ))} +
+ ); + + const renderView = () => { + switch (viewMode) { + case "grid": + return renderGridView(); + case "dense": + return renderDenseView(); + default: + return renderSplitView(); + } + }; return ( <> - {/* This is a workaround to ensure mobile job cards are unaffected by the scrollbar. */} -
- {jobs.map((job) => ( -
{ - setSelectedJob(job); - // Only open modal on mobile - if (window.innerWidth < 1024) { - setIsModalOpen(true); - } - }} - className="cursor-pointer" - > - -
- ))} +
+ {renderView()}
@@ -72,13 +124,19 @@ export default function JobList({ jobs }: JobListProps) { setIsModalOpen(false)} - size="lg" + size="xl" scrollAreaComponent={ScrollArea} - className="lg:hidden" - fullScreen + fullScreen={!isDesktop} + radius={isDesktop ? "lg" : undefined} styles={{ body: { - height: "calc(100svh - 100px)", + height: isDesktop ? "80vh" : "calc(100svh - 100px)", + }, + content: { + background: "#2e2e2e", + }, + header: { + background: "#2e2e2e", }, }} > diff --git a/frontend/src/components/jobs/job-summary.tsx b/frontend/src/components/jobs/job-summary.tsx index 6a51318..03ec27c 100644 --- a/frontend/src/components/jobs/job-summary.tsx +++ b/frontend/src/components/jobs/job-summary.tsx @@ -1,5 +1,4 @@ -// frontend/src/components/jobs/details/sections/job-description.tsx -import SectionHeading from "@/components/ui/section-heading"; +// frontend/src/components/jobs/job-summary.tsx import { IconRobot } from "@tabler/icons-react"; interface JobSummaryProps { @@ -8,12 +7,14 @@ interface JobSummaryProps { export default function JobSummary({ one_liner }: JobSummaryProps) { return ( -
- } - title="Summary" - /> - +
+
+ + + AI Summary + +
+ {one_liner}
diff --git a/frontend/src/components/jobs/jobs-content.tsx b/frontend/src/components/jobs/jobs-content.tsx new file mode 100644 index 0000000..958f9d0 --- /dev/null +++ b/frontend/src/components/jobs/jobs-content.tsx @@ -0,0 +1,59 @@ +// frontend/src/components/jobs/jobs-content.tsx +"use client"; + +import { useFilterContext } from "@/context/filter/filter-context"; +import JobList from "@/components/jobs/job-list"; +import JobDetails from "@/components/jobs/job-details"; +import { Job } from "@/types/job"; +import { Suspense } from "react"; +import JobListLoading from "@/components/layout/job-list-loading"; +import JobDetailsLoading from "@/components/layout/job-details-loading"; + +interface JobsContentProps { + jobs: Job[]; +} + +export default function JobsContent({ jobs }: JobsContentProps) { + const { viewMode } = useFilterContext(); + + if (viewMode === "grid") { + return ( + }> + + + ); + } + + if (viewMode === "dense") { + return ( +
+
+ }> + + +
+
+ }> + + +
+
+ ); + } + + // Split view (default) + return ( +
+
+ }> + + +
+
+ }> + + +
+
+ ); +} diff --git a/frontend/src/components/layout/initial-load.tsx b/frontend/src/components/layout/initial-load.tsx new file mode 100644 index 0000000..67a1342 --- /dev/null +++ b/frontend/src/components/layout/initial-load.tsx @@ -0,0 +1,19 @@ +// frontend/src/components/layout/initial-load.tsx +"use client"; + +import { motion } from "framer-motion"; +import { type ReactNode } from "react"; + +const EASE = [0.16, 1, 0.3, 1] as [number, number, number, number]; + +export function InitialLoad({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/layout/job-details-loading.tsx b/frontend/src/components/layout/job-details-loading.tsx index 222a680..7723bc2 100644 --- a/frontend/src/components/layout/job-details-loading.tsx +++ b/frontend/src/components/layout/job-details-loading.tsx @@ -1,44 +1,54 @@ // frontend/src/components/layout/job-details-loading.tsx -import { Card, ScrollArea } from "@mantine/core"; - export default function JobDetailsLoading() { return ( - - -
- {/* Header skeleton */} -
-
-
-
+
+
+ {/* Header skeleton */} +
+
+
+
+
+
+
+
-
+
+
- {/* Description section skeleton */} -
-
-
-
-
-
- {[...Array(8)].map((_, i) => ( -
- ))} -
+ {/* Summary skeleton */} +
+
+
+
+
+
- - {/* Action buttons skeleton */} -
-
-
-
+ {/* Description skeleton */} +
+
+
+
+
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+
+ + {/* Footer skeleton */} +
+
+
- +
); } diff --git a/frontend/src/components/layout/job-list-loading.tsx b/frontend/src/components/layout/job-list-loading.tsx index 310abf2..db1ef07 100644 --- a/frontend/src/components/layout/job-list-loading.tsx +++ b/frontend/src/components/layout/job-list-loading.tsx @@ -3,16 +3,31 @@ import { ScrollArea } from "@mantine/core"; export default function JobListLoading() { return ( -
- {[...Array(10)].map((_, i) => ( +
+ {[...Array(8)].map((_, i) => (
+ className="rounded-xl border border-[rgba(255,255,255,0.06)] bg-secondary p-4 animate-pulse" + > +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
))}
diff --git a/frontend/src/components/layout/main-content-loading.tsx b/frontend/src/components/layout/main-content-loading.tsx index 37e1ec8..5a227c8 100644 --- a/frontend/src/components/layout/main-content-loading.tsx +++ b/frontend/src/components/layout/main-content-loading.tsx @@ -1,17 +1,14 @@ -// frontend/src/app/jobs/loading.tsx +// frontend/src/components/layout/main-content-loading.tsx import JobListLoading from "@/components/layout/job-list-loading"; import JobDetailsLoading from "@/components/layout/job-details-loading"; export default function MainContentLoading() { - { - /* Main content area */ - } return (
-
+
-
+
diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index ae0fcbc..4aacf39 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -14,6 +14,7 @@ export const NavBarMobile = () => { const menuItems = [ { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, + { href: "https://monashcoding.com", label: "MAC ↗", external: true }, ]; return ( @@ -54,6 +55,7 @@ export const NavBarMobile = () => { key={item.href} component={Link} href={item.href} + target={"external" in item ? "_blank" : undefined} className={pathname === item.href ? "font-bold" : ""} > {item.label} diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index e3b11c0..1feb6d8 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { IconExternalLink } from "@tabler/icons-react"; export default function NavLinks() { const pathname = usePathname(); @@ -19,6 +20,14 @@ export default function NavLinks() { > Jobs + + MAC + + ); } diff --git a/frontend/src/components/layout/navigation-transition.tsx b/frontend/src/components/layout/navigation-transition.tsx new file mode 100644 index 0000000..f877104 --- /dev/null +++ b/frontend/src/components/layout/navigation-transition.tsx @@ -0,0 +1,122 @@ +// frontend/src/components/layout/navigation-transition.tsx +"use client"; + +import { motion } from "framer-motion"; +import { IconArrowRight, IconArrowLeft } from "@tabler/icons-react"; + +interface Props { + direction: 1 | -1; +} + +const LINE_COUNT = 14; +const ARROW_SIZE = 240; +const EASE = [0.19, 1, 0.22, 1] as const; + +function pseudoRandom(index: number, salt: number): number { + const x = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453; + return x - Math.floor(x); +} + +export function NavigationTransition({ direction }: Props) { + const Arrow = direction === 1 ? IconArrowRight : IconArrowLeft; + const startLeft = direction === 1 ? "-20vw" : "100vw"; + const endLeft = direction === 1 ? "100vw" : "-20vw"; + const animationName = + direction === 1 ? "mploy-rush-right" : "mploy-rush-left"; + + return ( + + {Array.from({ length: LINE_COUNT }).map((_, i) => ( + + ))} + + + + + + + + + + ); +} + +function SpeedLine({ + index, + direction, +}: { + index: number; + direction: 1 | -1; +}) { + const top = index * (100 / LINE_COUNT) + pseudoRandom(index, 1) * 1.5; + const width = 36 + pseudoRandom(index, 2) * 44; + const height = 2 + Math.floor(pseudoRandom(index, 3) * 2); + const duration = 0.45 + pseudoRandom(index, 4) * 0.2; + // Max delay 0.15s so all lines appear almost immediately — no late stragglers + const delay = pseudoRandom(index, 5) * 0.15; + const opacity = 0.25 + pseudoRandom(index, 6) * 0.35; + + return ( +
+ ); +} diff --git a/frontend/src/components/layout/page-transition.tsx b/frontend/src/components/layout/page-transition.tsx new file mode 100644 index 0000000..a2ec753 --- /dev/null +++ b/frontend/src/components/layout/page-transition.tsx @@ -0,0 +1,81 @@ +// frontend/src/components/layout/page-transition.tsx +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { NavigationTransition } from "./navigation-transition"; + +const EASE = [0.19, 1, 0.22, 1] as [number, number, number, number]; +const MIN_OVERLAY_MS = 620; +const SLIDE_VW = 40; + +const PAGE_ORDER: Record = { + "/": 0, + "/jobs": 1, +}; + +function orderOf(path: string): number { + if (PAGE_ORDER[path] !== undefined) return PAGE_ORDER[path]; + if (path.startsWith("/jobs")) return 1; + return 0; +} + +export function PageTransition({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const prev = useRef(pathname); + const [direction, setDirection] = useState<1 | -1>(1); + const [overlayActive, setOverlayActive] = useState(false); + const [hidden, setHidden] = useState(false); + const [slideKey, setSlideKey] = useState(0); + + useEffect(() => { + if (pathname === prev.current) return; + const dir: 1 | -1 = + orderOf(pathname) >= orderOf(prev.current) ? 1 : -1; + prev.current = pathname; + setDirection(dir); + + // Immediately hide content and show overlay + setHidden(true); + setOverlayActive(true); + + // After overlay duration, hide overlay and slide content in + const t = window.setTimeout(() => { + setOverlayActive(false); + setHidden(false); + setSlideKey((k) => k + 1); + }, MIN_OVERLAY_MS); + + return () => window.clearTimeout(t); + }, [pathname]); + + const enterX = `${direction * SLIDE_VW}vw`; + + return ( +
+ {hidden ? ( + // Content exists in DOM (so Next.js is happy) but invisible +
+ {children} +
+ ) : ( + 0 ? { x: enterX, opacity: 0 } : false} + animate={{ x: 0, opacity: 1 }} + transition={{ + x: { duration: 0.5, ease: EASE }, + opacity: { duration: 0.3, ease: EASE }, + }} + > + {children} + + )} + + + {overlayActive && } + +
+ ); +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 7e180c4..afd5d02 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -16,13 +16,18 @@ export default function Badge({ }: BadgeProps) { return ( {text} diff --git a/frontend/src/components/ui/dot-background.tsx b/frontend/src/components/ui/dot-background.tsx index 8fe5047..26a0a25 100644 --- a/frontend/src/components/ui/dot-background.tsx +++ b/frontend/src/components/ui/dot-background.tsx @@ -2,100 +2,190 @@ "use client"; import { useEffect, useRef } from "react"; +import { transitionState } from "@/lib/transition-state"; + +const GRID_SIZE = 25; +const DOT_SIZE = 1; +const CURSOR_RADIUS = 100; +const SWEEP_DURATION = 1000; +const MAX_STRETCH = 220; +const MAX_SHIFT = 80; +const WAVE_WIDTH = 0.55; +const YELLOW_CHANCE = 0; + +function seededRandom(index: number, salt: number): number { + const x = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453; + return x - Math.floor(x); +} + +function easeIn(t: number): number { + return t * t * t; +} + +function smoothstep(x: number): number { + const t = Math.max(0, Math.min(1, x)); + return t * t * (3 - 2 * t); +} export default function DotBackground() { const canvasRef = useRef(null); - const mouseRef = useRef({ x: -1000, y: -1000 }); // Start mouse off-screen + const mouseRef = useRef({ x: -1000, y: -1000 }); useEffect(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); - if (!canvas || !ctx) return; - // Handle resize - const resize = () => { + let w = 0; + let h = 0; + + interface Dot { + x: number; + y: number; + isYellow: boolean; + stretchMult: number; + shiftMult: number; + } + + let dots: Dot[] = []; + + const rebuild = () => { const { devicePixelRatio: ratio = 1 } = window; - canvas.width = window.innerWidth * ratio; - canvas.height = window.innerHeight * ratio; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; - ctx.scale(ratio, ratio); + w = window.innerWidth; + h = window.innerHeight; + canvas.width = w * ratio; + canvas.height = h * ratio; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + + const cols = Math.ceil(w / GRID_SIZE) + 1; + const rows = Math.ceil(h / GRID_SIZE) + 1; + dots = []; + let idx = 0; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + dots.push({ + x: c * GRID_SIZE, + y: r * GRID_SIZE, + isYellow: seededRandom(idx, 7) < YELLOW_CHANCE, + stretchMult: 0.5 + seededRandom(idx, 8) * 1.0, + shiftMult: 0.5 + seededRandom(idx, 9) * 1.0, + }); + idx++; + } + } }; - resize(); - window.addEventListener("resize", resize); - - // Grid configuration - const GRID_SIZE = 25; - const DOT_SIZE = 1; - const CURSOR_RADIUS = 100; - - // Create grid of dots - const dots: { x: number; y: number }[] = []; - const rows = Math.ceil(canvas.height / GRID_SIZE); - const cols = Math.ceil(canvas.width / GRID_SIZE); - - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cols; j++) { - dots.push({ - x: j * GRID_SIZE, - y: i * GRID_SIZE, - }); - } - } + rebuild(); + window.addEventListener("resize", rebuild); - // Handle mouse movement - attach to window instead of canvas const handleMouseMove = (e: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - mouseRef.current = { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }; + mouseRef.current = { x: e.clientX, y: e.clientY }; }; - - // Handle mouse leave const handleMouseLeave = () => { - mouseRef.current = { x: -1000, y: -1000 }; // Move mouse off-screen + mouseRef.current = { x: -1000, y: -1000 }; }; - window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseleave", handleMouseLeave); - // Animation loop - let animationFrameId: number; + let animId: number; + + function animate(now: number) { + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + + // Check if a transition sweep is active + const { active, direction, startTime } = transitionState; + let linearProgress = 0; + let easedProgress = 0; + let wavePos = -999; + let sweepActive = false; + + if (active) { + const elapsed = now - startTime; + linearProgress = Math.min(elapsed / SWEEP_DURATION, 1.0); + easedProgress = easeIn(linearProgress); + const totalTravel = 1 + WAVE_WIDTH * 2; + wavePos = direction === 1 + ? -WAVE_WIDTH + easedProgress * totalTravel + : 1 + WAVE_WIDTH - easedProgress * totalTravel; + sweepActive = linearProgress < 1.0; + + if (!sweepActive) { + transitionState.active = false; + } + } - function animate() { - if (ctx == null || canvas == null) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); + const mx = mouseRef.current.x; + const my = mouseRef.current.y; + + for (const dot of dots) { + // Cursor glow (always active) + const dx = dot.x - mx; + const dy = dot.y - my; + const cursorDist = Math.sqrt(dx * dx + dy * dy); + let cursorBrightness = 0; + if (cursorDist < CURSOR_RADIUS) { + cursorBrightness = (1 - cursorDist / CURSOR_RADIUS) * 0.3; + } - dots.forEach((dot) => { - const dx = dot.x - mouseRef.current.x; - const dy = dot.y - mouseRef.current.y; - const distance = Math.sqrt(dx * dx + dy * dy); + ctx.beginPath(); - let opacity = 0.08; // Lower base opacity - if (distance < CURSOR_RADIUS) { - opacity = 0.08 + (1 - distance / CURSOR_RADIUS) * 0.3; + if (sweepActive) { + const dotNorm = dot.x / w; + const dist = Math.abs(dotNorm - wavePos); + const influence = smoothstep(1 - dist / WAVE_WIDTH); + + const stretch = influence * MAX_STRETCH * dot.stretchMult; + const shift = influence * MAX_SHIFT * direction * dot.shiftMult; + + if (stretch > 2) { + // This dot is stretched into a line + const lineH = 1.5; + const sx = dot.x + shift - (direction === 1 ? stretch * 0.3 : stretch * 0.7); + const sy = dot.y - lineH / 2; + const r = lineH / 2; + + ctx.moveTo(sx + r, sy); + ctx.lineTo(sx + stretch - r, sy); + ctx.arcTo(sx + stretch, sy, sx + stretch, sy + r, r); + ctx.lineTo(sx + stretch, sy + lineH - r); + ctx.arcTo(sx + stretch, sy + lineH, sx + stretch - r, sy + lineH, r); + ctx.lineTo(sx + r, sy + lineH); + ctx.arcTo(sx, sy + lineH, sx, sy + lineH - r, r); + ctx.lineTo(sx, sy + r); + ctx.arcTo(sx, sy, sx + r, sy, r); + + // Stay the same gray tone as the dots — don't brighten + const a = 0.08 + influence * 0.04; + ctx.fillStyle = `rgba(255, 255, 255, ${a})`; + } else { + // Dot with a slight nudge + ctx.arc(dot.x + shift * 0.3, dot.y, DOT_SIZE, 0, Math.PI * 2); + const a = 0.08 + cursorBrightness; + ctx.fillStyle = `rgba(255, 255, 255, ${a})`; + } + } else { + // Normal dot rendering + ctx.arc(dot.x, dot.y, DOT_SIZE, 0, Math.PI * 2); + const a = 0.08 + cursorBrightness; + ctx.fillStyle = `rgba(255, 255, 255, ${a})`; } - ctx.beginPath(); - ctx.arc(dot.x, dot.y, DOT_SIZE, 0, Math.PI * 2); - ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`; ctx.fill(); - }); + } - animationFrameId = requestAnimationFrame(animate); + animId = requestAnimationFrame(animate); } - animate(); + animId = requestAnimationFrame(animate); - // Cleanup return () => { - window.removeEventListener("resize", resize); + window.removeEventListener("resize", rebuild); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseleave", handleMouseLeave); - cancelAnimationFrame(animationFrameId); + cancelAnimationFrame(animId); }; }, []); diff --git a/frontend/src/components/ui/no-results.tsx b/frontend/src/components/ui/no-results.tsx index cb105f9..30294e4 100644 --- a/frontend/src/components/ui/no-results.tsx +++ b/frontend/src/components/ui/no-results.tsx @@ -3,14 +3,9 @@ import { Button, Text } from "@mantine/core"; import { IconInboxOff } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; -import MainContentLoading from "@/components/layout/main-content-loading"; export default function NoResults() { - const { clearFilters, isLoading } = useFilterContext(); - - if (isLoading) { - return ; - } + const { clearFilters } = useFilterContext(); return (
diff --git a/frontend/src/context/filter/filter-context.tsx b/frontend/src/context/filter/filter-context.tsx index e7d981f..35c695d 100644 --- a/frontend/src/context/filter/filter-context.tsx +++ b/frontend/src/context/filter/filter-context.tsx @@ -2,7 +2,7 @@ // frontend/src/context/jobs/filter-context.tsx import { createContext, useContext } from "react"; -import { FilterState } from "@/types/filters"; +import { FilterState, ViewMode } from "@/types/filters"; import { Job } from "@/types/job"; interface FilterContextType { @@ -14,6 +14,8 @@ interface FilterContextType { setTotalJobs: (totalJobs: number) => void; isLoading: boolean; clearFilters: () => void; + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; } export const FilterContext = createContext( diff --git a/frontend/src/context/filter/filter-provider.tsx b/frontend/src/context/filter/filter-provider.tsx index e51cdc7..8602e28 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, useEffect, useState } from "react"; +import { ReactNode, useEffect, useState, useTransition } from "react"; import { FilterContext } from "./filter-context"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateQueryString } from "@/lib/utils"; -import { FilterState } from "@/types/filters"; +import { FilterState, ViewMode } from "@/types/filters"; +import { transitionState } from "@/lib/transition-state"; import { Job, IndustryField, @@ -71,20 +72,32 @@ export function FilterProvider({ children }: { children: ReactNode }) { const [filters, setFilters] = useState(initialFilterState); const [selectedJob, setSelectedJobInternal] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [isPending, startTransition] = useTransition(); + const [isNavigating, setIsNavigating] = useState(false); const [totalJobs, setTotalJobs] = useState(0); + const [viewMode, setViewMode] = useState("split"); + + const isLoading = isPending || isNavigating; const updateFilters = (newFilters: Partial) => { - setIsLoading(true); + setIsNavigating(true); setFilters((curr) => ({ ...curr, ...newFilters })); setSelectedJob(null); + + // Trigger dot background wave while loading + transitionState.active = true; + transitionState.direction = 1; + transitionState.startTime = performance.now(); + const params = CreateQueryString(newFilters); - router.push(`/jobs?${params}`); + startTransition(() => { + router.push(`/jobs?${params}`); + }); }; useEffect(() => { if (pathname === "/jobs") { - setIsLoading(false); + setIsNavigating(false); setSelectedJob(null); } }, [pathname, searchParams]); @@ -106,10 +119,17 @@ export function FilterProvider({ children }: { children: ReactNode }) { }; const clearFilters = () => { - setIsLoading(true); + setIsNavigating(true); setFilters(emptyFilterState); setSelectedJob(null); - router.push("/jobs"); + + transitionState.active = true; + transitionState.direction = -1; + transitionState.startTime = performance.now(); + + startTransition(() => { + router.push("/jobs"); + }); }; return ( @@ -123,6 +143,8 @@ export function FilterProvider({ children }: { children: ReactNode }) { updateFilters, setSelectedJob, clearFilters, + viewMode, + setViewMode, }} > {children} diff --git a/frontend/src/lib/transition-state.ts b/frontend/src/lib/transition-state.ts new file mode 100644 index 0000000..c12f76d --- /dev/null +++ b/frontend/src/lib/transition-state.ts @@ -0,0 +1,10 @@ +// Shared mutable state that DotBackground reads every frame +// and PageTransition writes to when navigation happens. +// Using a plain object (not React state) so the canvas animation +// loop can read it without re-renders. + +export const transitionState = { + active: false, + direction: 1 as 1 | -1, + startTime: 0, +}; diff --git a/frontend/src/types/filters.ts b/frontend/src/types/filters.ts index 9bdefcd..31b9b85 100644 --- a/frontend/src/types/filters.ts +++ b/frontend/src/types/filters.ts @@ -1,6 +1,8 @@ // frontend/src/types/filters.ts import { JobType, LocationType, WorkingRight, IndustryField } from "./job"; +export type ViewMode = "split" | "grid" | "dense"; + /** * JobFilters is a type that represents the filters that can be applied to the job search */