Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`, read-only, for the examples and
for projects people shared alike, and **Copy link** copies that address.
Expand Down
46 changes: 37 additions & 9 deletions canvas/src/Community.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Card
key={e.slug}
entry={e}
onOpen={() =>
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.
Expand All @@ -217,7 +253,7 @@ export function CommunityPage({
}: {
openExample?: (slug: string) => void;
}) {
const [all, setAll] = useState(examples);
const all = useCommunity();
const [family, setFamily] = useState<Family | "all">("all");
const [collection, setCollection] = useState<string | null>(null);
const [q, setQ] = useState("");
Expand Down Expand Up @@ -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();
Expand Down
170 changes: 71 additions & 99 deletions canvas/src/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,7 +16,6 @@ import {
type ProjectCanvas as Canvas,
type ProjectTab,
} from "./canvasTabs";
import { canvasPageUrl } from "./canvasUrl";
import {
askServer,
confirmTrash,
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -185,17 +179,15 @@ function Card(props: {
<b>{props.name}</b>
<small>{props.sub}</small>
</div>
<small>{props.count}</small>
</div>
</a>
);
}

/**
* 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.
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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)}`}
/>
);
})}
Expand All @@ -359,37 +347,25 @@ export function HomePage(props: {
</button>
)}
</div>
{examples.length > 0 && (
<>
<div className="home-bar">
<h2>Examples</h2>
</div>
<div className="home-grid">
{examples.map((c) => (
<Card
key={c.slug}
href={canvasPageUrl(c.slug)}
onClick={openInTab(props.goTo, tabOfExample(c.slug, tabs))}
onContextMenu={showMenu({
href: canvasPageUrl(c.slug),
tab: tabOfExample(c.slug, tabs),
})}
cover={projectCover([c])}
base={import.meta.env.BASE_URL}
updated={c.updated}
icon={c.icon ? canvasIconUrl(c.slug) : undefined}
name={nameOf(c)}
sub="Example"
count={plural(c.html.length, "board")}
/>
))}
</div>
</>
)}
<div className="home-bar">
<h2>Community</h2>
<div>
<button type="button" onClick={props.openCommunity}>
See all
</button>
</div>
</div>
{/* Two rows of the latest. An example opens on a tab of its own (canvasTabs.ts), not on the
project this window is on. */}
<ul className="home-grid">
<CommunityCards
limit={8}
openExample={(slug) => props.goTo(tabOfExample(slug, tabs))}
/>
</ul>
{/* 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. */}
<div
ref={menu}
popover="auto"
Expand Down Expand Up @@ -420,53 +396,49 @@ export function HomePage(props: {
>
Copy link
</button>
{target.project && (
<>
<button
type="button"
role="menuitem"
className="sp-menu-row"
onClick={() => askServer("reveal", target.project!.name)}
>
{REVEAL}
</button>
{/* Back to the first canvas's, once one was chosen from the canvas. */}
{target.project.cover?.chosen && (
<button
type="button"
role="menuitem"
className="sp-menu-row"
onClick={async () => {
if (await setProjectCover(target.project!.url, null))
props.reload();
}}
>
Reset cover
</button>
)}
<hr />
<button
type="button"
role="menuitem"
className="sp-menu-row sp-context-menu__danger"
onClick={async () => {
const p = target.project!;
if (
!(await confirmTrash(
p.name,
p.path,
"Everything in that folder goes with it.",
))
)
return;
await askServer("delete", p.name);
<button
type="button"
role="menuitem"
className="sp-menu-row"
onClick={() => askServer("reveal", target.project.name)}
>
{REVEAL}
</button>
{/* Back to the first canvas's, once one was chosen from the canvas. */}
{target.project.cover?.chosen && (
<button
type="button"
role="menuitem"
className="sp-menu-row"
onClick={async () => {
if (await setProjectCover(target.project.url, null))
props.reload();
}}
>
{TRASH}
</button>
</>
}}
>
Reset cover
</button>
)}
<hr />
<button
type="button"
role="menuitem"
className="sp-menu-row sp-context-menu__danger"
onClick={async () => {
const p = target.project;
if (
!(await confirmTrash(
p.name,
p.path,
"Everything in that folder goes with it.",
))
)
return;
await askServer("delete", p.name);
props.reload();
}}
>
{TRASH}
</button>
</>
)}
</div>
Expand Down
17 changes: 13 additions & 4 deletions canvas/src/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading