diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4e7186b5..d44b604e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -19,6 +19,15 @@ The app, its skills and the toolkit carry one version. Everything below is on `main` and reaches no install until a version is cut. +- **A community of projects.** The home page's Community tile, and a + Community section under your projects, list the app clones people shared to + the community repo, each with a cover drawn like a book's, and the examples. + Ask your agent to share a project and it opens the pull request for you; + `sp pack` checks one first. +- **Four projects to a row on the home page**, each cover at the shape of a + link preview. +- **A board can no longer reach the app.** Every board runs sandboxed, and the + websites you visit can no longer reach the app's server. - **Every project in the community opens on the web.** Its **Open** shows the project's canvas at `superproto.dev/p/`, read-only, for the examples and for projects people shared alike, and **Copy link** copies that address. diff --git a/canvas/src/Community.tsx b/canvas/src/Community.tsx index 2d97d18f..dffe630e 100644 --- a/canvas/src/Community.tsx +++ b/canvas/src/Community.tsx @@ -208,6 +208,42 @@ function Card({ entry, onOpen }: { entry: Entry; onOpen: () => void }) { ); } +/** Every project in the community. Offline, or with GitHub down, the examples alone. */ +function useCommunity() { + const [all, setAll] = useState(examples); + useEffect(() => { + fetch(`${RAW}index.json`) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then((index: Index) => setAll([...shared(index), ...examples()])) + .catch(() => {}); + }, []); + return all; +} + +/** + * The home page's Community section (HomePage.tsx): the latest projects as cards, each opening + * as it does from the community's dialog, an example as its tab and a shared project on the site. + */ +export function CommunityCards({ + openExample, + limit, +}: { + openExample: (slug: string) => void; + limit: number; +}) { + return useCommunity() + .slice(0, limit) + .map((e) => ( + + e.source ? open(webUrl(e), "_blank", "noopener") : openExample(e.slug) + } + /> + )); +} + /** * @param openExample The app's: opens an example as its tab. Without it, as on the site, and for * a shared project, Open is a link to the project's page on the site. @@ -217,7 +253,7 @@ export function CommunityPage({ }: { openExample?: (slug: string) => void; }) { - const [all, setAll] = useState(examples); + const all = useCommunity(); const [family, setFamily] = useState("all"); const [collection, setCollection] = useState(null); const [q, setQ] = useState(""); @@ -266,14 +302,6 @@ export function CommunityPage({ return () => removeEventListener("keydown", onKey); }, []); - // Offline, or with GitHub down, the page lists the examples alone. - useEffect(() => { - fetch(`${RAW}index.json`) - .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) - .then((index: Index) => setAll([...shared(index), ...examples()])) - .catch(() => {}); - }, []); - useEffect(() => { if (open && !dialog.current!.open) dialog.current!.showModal(); if (!open && dialog.current!.open) dialog.current!.close(); diff --git a/canvas/src/HomePage.tsx b/canvas/src/HomePage.tsx index 81dcabdf..b4be7b31 100644 --- a/canvas/src/HomePage.tsx +++ b/canvas/src/HomePage.tsx @@ -5,10 +5,9 @@ import { type MouseEventHandler, } from "react"; import { canvasIndex } from "./canvasIndex"; -import { canvasIconUrl, humanize } from "./canvasLibrary"; -import { fitCover, projectCover, type Cover } from "./cover"; +import { CommunityCards } from "./Community"; +import { fitCover, type Cover } from "./cover"; import { - isExample, openInTab, tabOfExample, tabOfProject, @@ -17,7 +16,6 @@ import { type ProjectCanvas as Canvas, type ProjectTab, } from "./canvasTabs"; -import { canvasPageUrl } from "./canvasUrl"; import { askServer, confirmTrash, @@ -29,15 +27,11 @@ import { import { FolderPlus, LogoDiscord, LogoGithub, Plus, Users } from "./geistIcons"; type Sort = "edited" | "name" | "boards"; -/** A card's right-click: the address it links to, the tab it opens, and its project if it is one. */ -type Target = { href: string; tab: ProjectTab; project?: Project }; +/** A card's right-click: the address it links to, the tab it opens, and its project. */ +type Target = { href: string; tab: ProjectTab; project: Project }; -/** The stage's height (home.css), which a cover fills. */ -const STAGE_H = 233; - -/** What a card calls a canvas: its layout's name without the "(example)" shelf, as tabs do. */ -const nameOf = (c: Canvas) => - (c.layout?.name ?? humanize(c.slug)).replace(/^\(example\)\s*/, ""); +/** The stage's shape (home.css), which a cover fills: an Open Graph image's, as a thumbnail is drawn at. */ +const STAGE_RATIO = 630 / 1200; const boardsIn = (canvases: Canvas[]) => canvases.reduce((n, c) => n + c.html.length, 0); @@ -145,19 +139,19 @@ function Card(props: { icon?: string; name: string; sub: string; - count: string; }) { const { cover } = props; const [stage, stageW] = useWidth(); + const stageH = stageW * STAGE_RATIO; // The cover fills the stage: an element chosen as cover centred in it, a whole board from its // top, the way a page is read. Clamped so the board is under every pixel of the stage. - const fit = cover && stageW > 0 && fitCover(cover.box, stageW, STAGE_H); + const fit = cover && stageW > 0 && fitCover(cover.box, stageW, stageH); const [x, y, w, h] = cover?.box ?? []; const whole = cover && x === 0 && y === 0 && w === cover.w && h === cover.h; const place = fit && { left: Math.min(0, Math.max(fit.left, stageW - cover.w * fit.scale)), top: !whole - ? Math.min(0, Math.max(fit.top, STAGE_H - cover.h * fit.scale)) + ? Math.min(0, Math.max(fit.top, stageH - cover.h * fit.scale)) : 0, }; return ( @@ -185,7 +179,6 @@ function Card(props: { {props.name} {props.sub} - {props.count} ); @@ -193,9 +186,8 @@ function Card(props: { /** * What the window shows under Home (AppShell.tsx), and what the desktop app opens on after the - * first launch: every project as a card, and the examples as projects of one canvas each. Drawn - * after Figma's home: a row of tiles for a new project and the two community links, a line of - * totals, then the cards. The bar above it and the agent's panel beside it are the window's. + * first launch: every project as a card, then the community's latest. Drawn after Figma's home: a + * row of tiles for a new project and the community links, a line of totals, then the cards. The bar above it and the agent's panel beside it are the window's. * * A project's card shows its one cover: the one chosen from the canvas's right button, else its * first canvas's (cover.ts). It opens the project as a tab, where the canvas strip lists the rest. @@ -225,9 +217,6 @@ export function HomePage(props: { (a, b) => boardsIn(b.canvases) - boardsIn(a.canvases), ) : byEdit(projects); - // The app's examples, which every project's server has, Start here first. Its card opens it - // on a tab of its own (canvasTabs.ts), not on the project this window is on. - const examples = canvasIndex().boards.filter((b) => isExample(b.slug)); const canvases = projects.flatMap((p) => p.canvases); const updated = Math.max(0, ...projects.map((p) => p.updated)); @@ -335,12 +324,11 @@ export function HomePage(props: { iconed && `${p.url}board/${encodeURI(iconed.slug)}/icon.png` } name={p.title ?? p.name} - sub={`Edited ${ago(p.updated)}`} - count={ + sub={`${ one ? plural(one.html.length, "board") : `${recent.length} ${recent.length === 1 ? "canvas" : "canvases"}` - } + } ยท ${ago(p.updated)}`} /> ); })} @@ -359,37 +347,25 @@ export function HomePage(props: { )} - {examples.length > 0 && ( - <> -
-

Examples

-
-
- {examples.map((c) => ( - - ))} -
- - )} +
+

Community

+
+ +
+
+ {/* Two rows of the latest. An example opens on a tab of its own (canvasTabs.ts), not on the + project this window is on. */} +
    + props.goTo(tabOfExample(slug, tabs))} + /> +
{/* One menu for every card, a native popover like the chat panel's: the top layer, and a click outside or Esc to shut it. A pick shuts it before the row's own click runs, so the - Trash's confirm is not drawn over it. An example is the plugin's, so it has no folder of - the user's to show or delete. */} + Trash's confirm is not drawn over it. */}
Copy link - {target.project && ( - <> - - {/* Back to the first canvas's, once one was chosen from the canvas. */} - {target.project.cover?.chosen && ( - - )} -
- + {/* Back to the first canvas's, once one was chosen from the canvas. */} + {target.project.cover?.chosen && ( + - + }} + > + Reset cover + )} +
+ )}
diff --git a/canvas/src/home.css b/canvas/src/home.css index d69c3720..31a9f3de 100644 --- a/canvas/src/home.css +++ b/canvas/src/home.css @@ -135,11 +135,17 @@ button.home-tile:not(.home-tile--community) i { background: var(--ds-gray-100); } +/* Four to a row, fewer once a quarter of the row is narrower than a card can be. */ .home-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr)); + grid-template-columns: repeat( + auto-fill, + minmax(max(min(220px, 100%), (100% - 3 * 24px) / 4), 1fr) + ); gap: 24px; + margin: 0; padding: 0 32px 48px; + list-style: none; } /* A project as a card: its cover filling the stage above its name. */ @@ -154,7 +160,7 @@ button.home-tile:not(.home-tile--community) i { .home-file__thumb { position: relative; overflow: hidden; - height: 233px; + aspect-ratio: 1200 / 630; background: var(--ds-gray-100); } .home-file__thumb iframe { @@ -194,17 +200,20 @@ button.home-tile:not(.home-tile--community) i { white-space: nowrap; } .home-file__foot small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--ds-gray-900); font-size: 13px; line-height: 18px; } -/* Before the first project, a card's outline and a card's height: the stage, the foot, the borders. */ +/* Before the first project, a card's outline, about a card's height at four to a row. */ .home-file--new { display: grid; place-content: center; justify-items: center; gap: 4px; - height: 295px; + min-height: 190px; border-style: dashed; border-color: var(--ds-gray-500); cursor: pointer;