diff --git a/apps/web/src/pages/project-cover.test.ts b/apps/web/src/pages/project-cover.test.ts new file mode 100644 index 00000000..20f79552 --- /dev/null +++ b/apps/web/src/pages/project-cover.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { coverArtifact, coverPage } from "./project-cover.tsx"; + +const item = (kind: string, artifactId: string) => ({ + artifactId, + kind, + title: kind, + version: 1, + sha256: "a".repeat(64), +}); + +test("the chosen design is the cover, else the first concept filed, else none", () => { + const brief = item("problem_brief", "brief"); + const first = item("design_option", "first"); + const second = item("design_option", "second"); + const chosen = item("design", "chosen"); + expect(coverArtifact([brief, first, second, chosen])?.artifactId).toBe( + "chosen", + ); + expect(coverArtifact([brief, first, second])?.artifactId).toBe("first"); + expect(coverArtifact([brief])).toBeNull(); + expect(coverArtifact([])).toBeNull(); +}); + +test("a bundle with no page to show leaves the tile its pattern", () => { + expect(coverPage({})).toBeNull(); + expect(coverPage({ "designs/index.html": " " })).toBeNull(); + expect( + coverPage({ + "designs/index.html": "
Hi
", + }), + ).toContain("
"); +}); diff --git a/apps/web/src/pages/project-cover.tsx b/apps/web/src/pages/project-cover.tsx new file mode 100644 index 00000000..b6f63fc0 --- /dev/null +++ b/apps/web/src/pages/project-cover.tsx @@ -0,0 +1,104 @@ +/** + * A project tile's cover: the first page of the design the person chose, or of the first concept + * filed before a choice, as a still picture; a project with no mockup keeps its pattern. The + * mockup is read only once the tile is on screen, so a long list does not read every design. + */ +import { useQuery } from "@tanstack/react-query"; +import type { ProjectArtifact } from "@corbits-builder/client"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { listProjectArtifacts, readArtifact } from "../api/artifacts.ts"; +import { keys } from "../queries/keys.ts"; +import { firstPage, parseBundle } from "./workspace/design-preview.tsx"; +import { GuiFrame } from "./workspace/gui-frame.tsx"; + +const COVER_STALE_MS = 5 * 60_000; +// A tile a screen's height away starts reading, so it is there by the time it scrolls in. +const AHEAD = "100%"; + +/** The mockup a tile shows: the design the person chose, else the first concept filed, else none. */ +export function coverArtifact( + artifacts: readonly ProjectArtifact[], +): ProjectArtifact | null { + const chosen = artifacts.find((item) => item.kind === "design"); + if (chosen !== undefined) return chosen; + const concept = artifacts.find((item) => item.kind === "design_option"); + return concept === undefined ? null : concept; +} + +/** The bundle's first page as the tile shows it, or null for a bundle with no page to show yet. */ +export function coverPage( + files: Readonly>, +): string | null { + const page = firstPage(files); + return page.trim() === "" ? null : page; +} + +async function readCoverPage(projectId: string): Promise { + const { artifacts } = await listProjectArtifacts(projectId); + const pick = coverArtifact(artifacts); + if (pick === null) return null; + const filed = await readArtifact(projectId, pick.artifactId, pick.version); + const files = parseBundle(filed.content); + return files === null ? null : coverPage(files); +} + +/** True once the element has come within a screen of the viewport, and from then on. */ +function useNearView(ref: React.RefObject): boolean { + const [near, setNear] = useState(false); + useEffect(() => { + const node = ref.current; + if (node === null || near) return; + if (typeof IntersectionObserver === "undefined") { + setNear(true); + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) setNear(true); + }, + { rootMargin: AHEAD }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [near]); + return near; +} + +export function ProjectCover({ + projectId, + pattern, + style, + mockup, +}: { + projectId: string; + pattern: string; + style: CSSProperties; + /** Whether to show the mockup at all; a list's small swatch shows the pattern only. */ + mockup: boolean; +}) { + const ref = useRef(null); + const near = useNearView(ref); + const cover = useQuery({ + queryKey: keys.projectCover.of(projectId), + queryFn: () => readCoverPage(projectId), + enabled: mockup && near, + staleTime: COVER_STALE_MS, + retry: false, + refetchOnWindowFocus: false, + }); + const html = mockup && cover.data !== undefined ? cover.data : null; + return ( + // A still picture: inert, so it neither takes focus nor eats the click that opens the project. + + ); +} diff --git a/apps/web/src/pages/project-library.css b/apps/web/src/pages/project-library.css index 97b7e0cd..0f0a40a7 100644 --- a/apps/web/src/pages/project-library.css +++ b/apps/web/src/pages/project-library.css @@ -90,6 +90,27 @@ bottom: var(--space-3); } +/* A cover that shows the mockup lays its first page out at four times the tile's width, the way a + desktop would, and scales it down. The frame is white like every other mockup frame, so a page + that sets no background of its own reads the same here as in the themes pane. */ +.library-page .library-cover[data-mockup] { + position: relative; + overflow: hidden; +} + +.library-page .library-cover .gui-frame { + position: absolute; + inset: 0 auto auto 0; + width: 400%; + height: 400%; + min-height: 0; + border: 0; + background: #fff; + transform: scale(0.25); + transform-origin: 0 0; + pointer-events: none; +} + /* Each cover mixes two theme colours into the card's own surface, so it stays soft in light mode and does not glare in dark mode. */ .library-page .library-cover { diff --git a/apps/web/src/pages/project-library.tsx b/apps/web/src/pages/project-library.tsx index 6a76f195..737875cc 100644 --- a/apps/web/src/pages/project-library.tsx +++ b/apps/web/src/pages/project-library.tsx @@ -29,6 +29,7 @@ import { import type { ProjectStage } from "@corbits-builder/schemas/project"; import type { ProjectSummary } from "../api/types.ts"; import type { NoticeAction } from "../components.jsx"; +import { ProjectCover } from "./project-cover.tsx"; import { ProjectMenu } from "./project-menu.jsx"; import "./project-library.css"; @@ -229,11 +230,11 @@ export function ProjectLibrary({ className="library-card-open" onClick={() => onOpen(project.id)} > -