Repository navigation
feat(web): show a project's chosen or first mockup on its tile #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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": "<html><body><main>Hi</main></body></html>", | ||
| }), | ||
| ).toContain("<main>"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Record<string, string>>, | ||
| ): string | null { | ||
| const page = firstPage(files); | ||
| return page.trim() === "" ? null : page; | ||
| } | ||
|
|
||
| async function readCoverPage(projectId: string): Promise<string | null> { | ||
| 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<HTMLElement | null>): 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<HTMLSpanElement>(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. | ||
| <span | ||
| ref={ref} | ||
| className="library-cover" | ||
| data-pattern={pattern} | ||
| data-mockup={html === null ? undefined : ""} | ||
| style={style} | ||
| aria-hidden="true" | ||
| inert | ||
| > | ||
| {html === null ? null : <GuiFrame html={html} scripts />} | ||
| </span> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)} | ||
| > | ||
| <span | ||
| className="library-cover" | ||
| data-pattern={cover.pattern} | ||
| <ProjectCover | ||
| projectId={project.id} | ||
| pattern={cover.pattern} | ||
| style={cover.style} | ||
| aria-hidden="true" | ||
| mockup={view === "grid"} | ||
|
Comment on lines
+233
to
+237
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 · Tile can keep showing an old mockup after the project's design changes The cover query's key is just the projectId, it stays fresh for 5 minutes, and nothing anywhere calls invalidate on keys.projectCover. Its key also doesn't include anything like project.lastActivityAt. So after a person picks a design or a new concept is filed, going back to the library shows the cached cover from before. Open the library so covers load, open a project, choose a design or file a concept, then return to the library within 5 minutes. The tile still shows the earlier concept, or the plain pattern if the project had no mockup before. Resolve this comment. Triage will not finish while it is open. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 · Grid view runs a scripted iframe for every tile and never unmounts them Each grid tile that has a mockup mounts a GuiFrame iframe with scripts on. Once a tile gets near the viewport, its iframe stays mounted even after the tile scrolls away. On a large library this means many live documents running the designs' scripts and animations at once, which costs a lot of memory and CPU. Scroll through a library with dozens of projects in grid view. Resolve this comment. Triage will not finish while it is open. |
||
| /> | ||
|
Comment on lines
+233
to
238
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 · Tile shows the text "No design to show." when the bundle has no index page or an empty one readCoverPage returns firstPage(files), and firstPage falls back to "" when designs/index is missing. Because "" is not null, ProjectCover sets data-mockup and renders GuiFrame. GuiFrame treats empty HTML as no design and renders a No design to show. inside the tile's cover, in place of the pattern the PR says should remain.A project whose chosen design or first concept has a bundle with no designs/index page (for example a concept filed before its index was written, or an empty bundle), viewed in grid mode. Resolve this comment. Triage will not finish while it is open. |
||
| <span className="library-card-body"> | ||
| <span className="library-card-title">{project.title}</span> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2 · Each tile calls the heavy room-listing endpoint and downloads the whole design bundle
listProjectArtifacts hits the hub's service.list. That handler queries workflow runs and pending approvals across the tenant, reads failure details for failed runs, and runs approvedVersion and filedAt queries for every artifact. readArtifact then downloads the full design bundle. All of this is only to show a thumbnail, and it runs once for every grid tile that comes near the viewport.
A library with many projects in grid view, scrolled through: one full room listing and one full bundle download per tile.
Resolve this comment. Triage will not finish while it is open.