diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index a5bc2ad2..95b3f852 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -337,6 +337,11 @@ export const wording = { archived: "Archived", }, lastActivity: (relative: string) => `Active ${relative}`, + view: { + label: "Show projects as", + grid: "Tiles", + rows: "List", + }, loading: "Reading your projects…", emptyTitle: "No projects yet", emptyLead: "Start one from New project; it shows here from then on.", diff --git a/apps/web/src/library-view-setting.test.ts b/apps/web/src/library-view-setting.test.ts new file mode 100644 index 00000000..06dad2d7 --- /dev/null +++ b/apps/web/src/library-view-setting.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; +import { parseLibraryView } from "./library-view-setting.ts"; + +test("only the two layouts are read back; anything else leaves the default", () => { + expect(parseLibraryView("grid")).toBe("grid"); + expect(parseLibraryView("rows")).toBe("rows"); + expect(parseLibraryView("tiles")).toBeNull(); + expect(parseLibraryView("")).toBeNull(); +}); diff --git a/apps/web/src/library-view-setting.ts b/apps/web/src/library-view-setting.ts new file mode 100644 index 00000000..529fe3a1 --- /dev/null +++ b/apps/web/src/library-view-setting.ts @@ -0,0 +1,24 @@ +/** + * How the Projects page lays projects out, tiles or a list of one-liners, kept + * in this browser. Tiles until the person chooses. + */ +import { + useStoredPreference, + writeStoredPreference, +} from "./stored-preference.ts"; + +const LIBRARY_VIEW_KEY = "builder-library-view"; + +/** Tiles, or a list of one line per project. */ +export type ViewMode = "grid" | "rows"; + +export const parseLibraryView = (raw: string): ViewMode | null => + raw === "grid" || raw === "rows" ? raw : null; + +export function useLibraryView(): ViewMode { + return useStoredPreference(LIBRARY_VIEW_KEY, parseLibraryView, "grid"); +} + +export function writeLibraryView(mode: ViewMode): void { + writeStoredPreference(LIBRARY_VIEW_KEY, mode); +} diff --git a/apps/web/src/pages/project-library.css b/apps/web/src/pages/project-library.css index bd344c59..97b7e0cd 100644 --- a/apps/web/src/pages/project-library.css +++ b/apps/web/src/pages/project-library.css @@ -142,3 +142,56 @@ ), color-mix(in oklab, var(--cover-b) 15%, var(--wb-card)); } + +.library-page .library-view { + display: inline-flex; + gap: var(--space-1); +} + +/* As a list: one line per project, a small swatch of its cover at the start, + the stage and last activity after the name, and the menu at the end. */ +.library-page .library-grid[data-view="rows"] { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.library-page .library-grid[data-view="rows"] .library-card-open { + flex-direction: row; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-12) var(--space-2) var(--space-3); +} + +.library-page .library-grid[data-view="rows"] .library-cover { + flex: none; + width: 2rem; + aspect-ratio: 1; + border-radius: var(--radius-lg); +} + +.library-page .library-grid[data-view="rows"] .library-card-body { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: var(--space-3); + padding: 0; +} + +.library-page .library-grid[data-view="rows"] .library-card-title { + flex: 1; + min-width: 0; +} + +.library-page .library-grid[data-view="rows"] .library-card-meta { + flex-wrap: nowrap; + padding-right: 0; + white-space: nowrap; +} + +.library-page .library-grid[data-view="rows"] .library-card-menu { + top: 50%; + bottom: auto; + transform: translateY(-50%); +} diff --git a/apps/web/src/pages/project-library.tsx b/apps/web/src/pages/project-library.tsx index 13655bd1..6a76f195 100644 --- a/apps/web/src/pages/project-library.tsx +++ b/apps/web/src/pages/project-library.tsx @@ -13,8 +13,19 @@ import { formatRelativeTime, type BadgeTone, } from "@corbits/react-ui"; -import { Ellipsis, Plus } from "lucide-react"; -import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { Ellipsis, LayoutGrid, List, Plus } from "lucide-react"; +import { + useLibraryView, + writeLibraryView, + type ViewMode, +} from "../library-view-setting.ts"; +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; import type { ProjectStage } from "@corbits-builder/schemas/project"; import type { ProjectSummary } from "../api/types.ts"; import type { NoticeAction } from "../components.jsx"; @@ -75,6 +86,42 @@ function coverOf(projectId: string) { }; } +/** + * Tiles or a list, as two pressed-state buttons in a group: each names itself for assistive tech, + * and the page owns the choice, which outlives the page. + */ +function ViewToggle({ + mode, + onChange, +}: { + mode: ViewMode; + onChange: (mode: ViewMode) => void; +}) { + const text = wording.library.view; + return ( +
+ + +
+ ); +} + export function ProjectLibrary({ projects, loaded, @@ -100,6 +147,7 @@ export function ProjectLibrary({ ) => void; }) { const [filter, setFilter] = useState(null); + const view = useLibraryView(); const sorted = projects.toSorted((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt), ); @@ -134,6 +182,111 @@ export function ProjectLibrary({ next?.focus(); }, [projects, shown]); + let body: ReactNode; + if (!loaded) + body = ( +

+ {failed ? wording.library.failed : wording.library.loading} +

+ ); + else if (shown.length === 0) + body = + filter === null ? ( + + {wording.shell.newProject} + + } + /> + ) : ( + + ); + else + body = ( +
    + {shown.map((project, index) => { + const cover = coverOf(project.id); + return ( +
  • + + +
  • + ); + })} +
+ ); return (
))} + - {!loaded ? ( -

- {failed ? wording.library.failed : wording.library.loading} -

- ) : shown.length === 0 ? ( - filter === null ? ( - - {wording.shell.newProject} - - } - /> - ) : ( - - ) - ) : ( -
    - {shown.map((project, index) => { - const cover = coverOf(project.id); - return ( -
  • - - -
  • - ); - })} -
- )} + {body}
); }