diff --git a/apps/web/locales/en-US.po b/apps/web/locales/en-US.po
index b25202311..4a6aef8d6 100644
--- a/apps/web/locales/en-US.po
+++ b/apps/web/locales/en-US.po
@@ -1103,6 +1103,10 @@ msgstr "Command Palette - {currentPage}"
msgid "Community"
msgstr "Community"
+#: src/routes/dashboard/resumes/index.tsx
+msgid "Compact"
+msgstr "Compact"
+
#: src/dialogs/resume/sections/experience.tsx
#: src/features/applications/components/application-form-sheet.tsx
msgid "Company"
diff --git a/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx b/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx
index d1a27a6f5..908f8135e 100644
--- a/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx
+++ b/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx
@@ -1,6 +1,7 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, m } from "motion/react";
+import { cn } from "@reactive-resume/utils/style";
import { CreateResumeCard } from "./cards/create-card";
import { ImportResumeCard } from "./cards/import-card";
import { ResumeCard } from "./cards/resume-card";
@@ -10,9 +11,17 @@ type Resume = RouterOutput["resume"]["list"][number];
type Props = {
resumes: Resume[];
hasResumes: boolean;
+ compact?: boolean;
};
-export function GridView({ resumes, hasResumes }: Props) {
+export function GridView({ resumes, hasResumes, compact = false }: Props) {
+ const gridClassName = cn(
+ "grid gap-4",
+ compact
+ ? "3xl:grid-cols-8 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6"
+ : "3xl:grid-cols-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5",
+ );
+
if (resumes.length === 0 && hasResumes) {
return (
@@ -23,7 +32,7 @@ export function GridView({ resumes, hasResumes }: Props) {
if (resumes.length === 0) {
return (
-
+
+
{resumes.map((resume, index) => (
{
+ sessionStorage.clear();
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("resume view preference", () => {
+ it("restores the selected view after leaving and returning during the session", async () => {
+ const first = renderHook(() => useResumeView("compact", "owner"));
+ await waitFor(() => expect(sessionStorage.getItem("resume-view:owner")).toBe('"compact"'));
+ first.unmount();
+ const second = renderHook(() => useResumeView(undefined, "owner"));
+ await waitFor(() => expect(second.result.current).toBe("compact"));
+ });
+ it("honors explicit grid in the URL over a saved compact preference", async () => {
+ sessionStorage.setItem("resume-view:owner", '"compact"');
+ const { result } = renderHook(() => useResumeView("grid", "owner"));
+ expect(result.current).toBe("grid");
+ await waitFor(() => expect(sessionStorage.getItem("resume-view:owner")).toBe('"grid"'));
+ });
+ it("keeps the preference scoped to its account", async () => {
+ sessionStorage.setItem("resume-view:first", '"list"');
+ const { result, rerender } = renderHook(({ user }) => useResumeView(undefined, user), {
+ initialProps: { user: "first" },
+ });
+ await waitFor(() => expect(result.current).toBe("list"));
+ rerender({ user: "second" });
+ await waitFor(() => expect(result.current).toBe("grid"));
+ });
+ it("ignores unknown stored views", () => {
+ sessionStorage.setItem("resume-view:owner", '"unknown"');
+ const { result } = renderHook(() => useResumeView(undefined, "owner"));
+ expect(result.current).toBe("grid");
+ });
+ it("renders the default when session storage is unavailable", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new DOMException("Blocked", "SecurityError");
+ });
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { result } = renderHook(() => useResumeView(undefined, "owner"));
+ expect(result.current).toBe("grid");
+ });
+});
diff --git a/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts b/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts
new file mode 100644
index 000000000..e9c4f0cbc
--- /dev/null
+++ b/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+import { useSessionStorage } from "usehooks-ts";
+import z from "zod";
+
+export const resumeViewSchema = z.enum(["grid", "compact", "list"]);
+type ResumeView = z.infer;
+
+export function useResumeView(view: ResumeView | undefined, userId: string): ResumeView {
+ const [storedView, setStoredView] = useSessionStorage(`resume-view:${userId}`, "grid", {
+ initializeWithValue: false,
+ });
+
+ useEffect(() => {
+ if (view !== undefined && storedView !== view) setStoredView(view);
+ }, [view, storedView, setStoredView]);
+
+ return view ?? resumeViewSchema.catch("grid").parse(storedView);
+}
diff --git a/apps/web/src/routes/dashboard/resumes/index.tsx b/apps/web/src/routes/dashboard/resumes/index.tsx
index 171aae85c..9db6bf077 100644
--- a/apps/web/src/routes/dashboard/resumes/index.tsx
+++ b/apps/web/src/routes/dashboard/resumes/index.tsx
@@ -4,6 +4,7 @@ import { Trans } from "@lingui/react/macro";
import {
DownloadSimpleIcon,
GridFourIcon,
+ GridNineIcon,
ListIcon,
MagnifyingGlassIcon,
PlusIcon,
@@ -25,6 +26,7 @@ import { orpc } from "@/libs/orpc/client";
import { DashboardHeader } from "../-components/header";
import { GridView } from "./-components/grid-view";
import { ListView } from "./-components/list-view";
+import { resumeViewSchema, useResumeView } from "./-components/view-mode";
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
@@ -32,12 +34,12 @@ const searchSchema = z.object({
search: z.string().default(""),
tags: z.array(z.string()).default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
- view: z.enum(["grid", "list"]).default("grid"),
+ view: resumeViewSchema.optional().catch(undefined),
});
type Search = z.output;
-const defaultSearch: Search = { search: "", tags: [], sort: "lastUpdatedAt", view: "grid" };
+const defaultSearch: Search = { search: "", tags: [], sort: "lastUpdatedAt" };
export const Route = createFileRoute("/dashboard/resumes/")({
component: RouteComponent,
@@ -49,7 +51,9 @@ export const Route = createFileRoute("/dashboard/resumes/")({
function RouteComponent() {
const { i18n } = useLingui();
- const { search, tags, sort, view } = Route.useSearch();
+ const { search, tags, sort, view: searchView } = Route.useSearch();
+ const { session } = Route.useRouteContext();
+ const view = useResumeView(searchView, session.user.id);
const navigate = useNavigate({ from: Route.fullPath });
const { openDialog } = useDialogStore();
@@ -153,7 +157,7 @@ function RouteComponent() {
)}
-
+
Grid
+ ({ ...prev, view: "compact" })} />}
+ >
+
+ Compact
+
+
0} />
) : (
- 0} />
+ 0} compact={view === "compact"} />
)}
);
diff --git a/tests/e2e/specs/resume-views.spec.ts b/tests/e2e/specs/resume-views.spec.ts
new file mode 100644
index 000000000..5405f08c4
--- /dev/null
+++ b/tests/e2e/specs/resume-views.spec.ts
@@ -0,0 +1,46 @@
+import { createSampleResumeFromDashboard } from "../fixtures/resume";
+import { expect, test } from "../fixtures/test";
+
+test("keeps compact and list preferences through navigation and reload, with explicit URL overrides", async ({
+ authPage: page,
+}, testInfo) => {
+ test.setTimeout(60_000);
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ const name = await createSampleResumeFromDashboard(page, testInfo);
+ await page.goto("/dashboard/resumes");
+ const card = page.getByRole("link").filter({ hasText: name });
+ await expect(card).toBeVisible();
+ const gridWidth = (await card.boundingBox())?.width;
+ if (!gridWidth) throw new Error("Resume card has no width.");
+ await page.getByRole("tab", { name: "Compact", exact: true }).click();
+ await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect.poll(async () => (await card.boundingBox())?.width ?? gridWidth).toBeLessThan(gridWidth);
+ await card.click();
+ await page.waitForURL(/\/builder\/.+/);
+ await page.goto("/dashboard/resumes");
+ await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true");
+ await page.reload();
+ await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect(card.locator('[style*="background-image: url("]')).toBeVisible({ timeout: 30_000 });
+ await page.screenshot({ path: testInfo.outputPath("compact-resumes.png"), animations: "disabled" });
+
+ await page.getByRole("tab", { name: "Grid", exact: true }).click();
+ await expect(page).toHaveURL(/view=grid/);
+ await expect(page.getByRole("tab", { name: "Grid", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect.poll(async () => (await card.boundingBox())?.width).toBe(gridWidth);
+ await page.getByRole("tab", { name: "List", exact: true }).click();
+ await expect(page.getByRole("tab", { name: "List", exact: true })).toHaveAttribute("aria-selected", "true");
+ await page.goto("/dashboard/resumes");
+ await expect(page.getByRole("tab", { name: "List", exact: true })).toHaveAttribute("aria-selected", "true");
+ await page.goto("/dashboard/resumes?view=grid");
+ await expect(page.getByRole("tab", { name: "Grid", exact: true })).toHaveAttribute("aria-selected", "true");
+ await page.goto("/dashboard/resumes?view=invalid");
+ await expect(page.getByRole("tab", { name: "Grid", exact: true })).toHaveAttribute("aria-selected", "true");
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.getByRole("tab", { name: "Compact", exact: true }).click();
+ await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true");
+ await expect(card).toBeVisible();
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
+ await page.screenshot({ path: testInfo.outputPath("compact-resumes-mobile.png"), animations: "disabled" });
+});