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 (
+
+ );
+}
+
+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 */}
+
}
className="flex-grow"
+ radius="md"
+ size="md"
>
Apply Now
-
- {isCopied ? : }
-
:
}
- style={{ transition: "background-color 0.3s ease" }}
+ radius="md"
+ size="md"
>
{isCopied ? "Copied!" : "Copy Link"}
+
-
+
);
}
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
*/