Site · Showcase: apps built with Ely, from the public Ely-GPUI-Showcases repo at a pinned commit
The build fetches showcase.lock's commit and runs the catalog's own validator. /showcase/ lists featured apps first, then the rest by newest or stars, with categories and search; /showcase/<id>/ shows previews, links and the README. The header drops its sound switch below 414px so the fourth link fits.
This commit is contained in:
1 parent
78bf5917a5
commit
faf0987cf8
17 files changed
+567
-13
No files matched your search
@@ -23,6 +23,9 @@ function Header() {
|
||||
<Link to="/docs/" className="link" activeProps={{ "aria-current": "page" }}>
|
||||
Docs
|
||||
</Link>
|
||||
<Link to="/showcase/" className="link" activeProps={{ "aria-current": "page" }}>
|
||||
Showcase
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="end">
|
||||
<button className="tool sound" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { counts, filtered, sorted, split, type App, type Catalog } from "./apps.ts";
|
||||
|
||||
const app = (id: string, publishedAt: string, more: Partial<App> = {}): App => ({
|
||||
id,
|
||||
name: id,
|
||||
author: "Ada",
|
||||
description: `${id} does things.`,
|
||||
category: "dev",
|
||||
platforms: ["Linux"],
|
||||
website: null,
|
||||
source: `https://github.com/ada/${id}`,
|
||||
publishedAt,
|
||||
previews: ["preview0.png"],
|
||||
html: null,
|
||||
...more,
|
||||
});
|
||||
|
||||
const old = app("old", "2026-01-01T00:00:00Z", { stars: 900 });
|
||||
const mid = app("mid", "2026-05-01T00:00:00+08:00", { category: "work", author: "Grace" });
|
||||
const fresh = app("fresh", "2026-09-01T00:00:00Z", { stars: 5 });
|
||||
const twin = app("twin", "2026-09-01T00:00:00Z", { stars: 50 });
|
||||
const catalog: Catalog = { source: "test", categories: { dev: "Developer Tools", work: "Productivity & Media", system: "System & Desktop" }, featured: ["mid", "old"], apps: [old, mid, fresh, twin] };
|
||||
|
||||
test("newest comes first, and stars break a tie", () => {
|
||||
assert.deepEqual(sorted(catalog.apps, "newest").map((a) => a.id), ["twin", "fresh", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("most stars comes first, and an app without stars comes last", () => {
|
||||
assert.deepEqual(sorted(catalog.apps, "stars").map((a) => a.id), ["old", "twin", "fresh", "mid"]);
|
||||
});
|
||||
|
||||
test("featured apps keep the catalog's order, and the rest leave them out", () => {
|
||||
const { featured, rest } = split(catalog);
|
||||
assert.deepEqual(featured.map((a) => a.id), ["mid", "old"]);
|
||||
assert.deepEqual(rest.map((a) => a.id), ["fresh", "twin"]);
|
||||
});
|
||||
|
||||
test("a featured id with no app fails", () => {
|
||||
assert.throws(() => split({ ...catalog, featured: ["ghost"] }), /featured ghost is no app/);
|
||||
});
|
||||
|
||||
test("search reads the name, the author and the description, in any case", () => {
|
||||
assert.deepEqual(filtered(catalog.apps, "FRESH", null).map((a) => a.id), ["fresh"]);
|
||||
assert.deepEqual(filtered(catalog.apps, "grace", null).map((a) => a.id), ["mid"]);
|
||||
assert.deepEqual(filtered(catalog.apps, "twin does", null).map((a) => a.id), ["twin"]);
|
||||
assert.deepEqual(filtered(catalog.apps, " ", null).length, 4);
|
||||
});
|
||||
|
||||
test("a category narrows the search", () => {
|
||||
assert.deepEqual(filtered(catalog.apps, "", "work").map((a) => a.id), ["mid"]);
|
||||
assert.deepEqual(filtered(catalog.apps, "fresh", "work"), []);
|
||||
});
|
||||
|
||||
test("every category counts its apps, empty ones too", () => {
|
||||
assert.deepEqual(counts(catalog), [["dev", "Developer Tools", 3], ["work", "Productivity & Media", 1], ["system", "System & Desktop", 0]]);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/** An app from the showcase catalog, as `scripts/showcase.mjs` writes it. */
|
||||
export interface App {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
category: string;
|
||||
platforms: string[];
|
||||
website: string | null;
|
||||
source: string | null;
|
||||
publishedAt: string;
|
||||
previews: string[];
|
||||
building?: boolean;
|
||||
stars?: number;
|
||||
starsUpdatedAt?: string;
|
||||
/** The README, rendered; null without one. */
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
source: string;
|
||||
categories: Record<string, string>;
|
||||
featured: string[];
|
||||
apps: App[];
|
||||
}
|
||||
|
||||
export type Sort = "newest" | "stars";
|
||||
|
||||
export const SUBMIT = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Showcases#submit-an-app";
|
||||
|
||||
/** Newest first, then most stars; or most stars, then newest. */
|
||||
export function sorted(apps: App[], sort: Sort): App[] {
|
||||
const time = (a: App) => Date.parse(a.publishedAt);
|
||||
const stars = (a: App) => a.stars ?? -1;
|
||||
const [first, second] = sort === "newest" ? [time, stars] : [stars, time];
|
||||
return [...apps].sort((a, b) => first(b) - first(a) || second(b) - second(a) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** The featured apps in their order, and the rest. */
|
||||
export function split(catalog: Catalog): { featured: App[]; rest: App[] } {
|
||||
const byId = new Map(catalog.apps.map((a) => [a.id, a]));
|
||||
const featured = catalog.featured.map((id) => {
|
||||
const app = byId.get(id);
|
||||
if (!app) throw new Error(`showcase: featured ${id} is no app`);
|
||||
return app;
|
||||
});
|
||||
return { featured, rest: catalog.apps.filter((a) => !catalog.featured.includes(a.id)) };
|
||||
}
|
||||
|
||||
/** Apps in a category, or all with none, whose name, author or description holds the query. */
|
||||
export function filtered(apps: App[], query: string, category: string | null): App[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return apps.filter((a) => (!category || a.category === category) && (!q || [a.name, a.author, a.description].some((s) => s.toLowerCase().includes(q))));
|
||||
}
|
||||
|
||||
/** How many apps each category holds. */
|
||||
export function counts(catalog: Catalog): [string, string, number][] {
|
||||
return Object.entries(catalog.categories).map(([key, label]) => [key, label, catalog.apps.filter((a) => a.category === key).length]);
|
||||
}
|
||||
|
||||
export const cover = (app: App) => `/showcase/${app.id}/${app.previews[0]}`;
|
||||
@@ -8,6 +8,8 @@ import { Chapters } from "./pages/Chapters";
|
||||
import { Docs } from "./pages/Docs";
|
||||
import { Home } from "./pages/Home";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { Showcase } from "./pages/Showcase";
|
||||
import { ShowcaseApp } from "./pages/ShowcaseApp";
|
||||
import { Story } from "./pages/Story";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -27,9 +29,11 @@ const chapter = createRoute({
|
||||
const story = createRoute({ getParentRoute: () => root, path: "/components/$page/$story", component: Story });
|
||||
const docs = createRoute({ getParentRoute: () => root, path: "/docs", component: Docs });
|
||||
const doc = createRoute({ getParentRoute: () => root, path: "/docs/$page", component: Docs });
|
||||
const showcase = createRoute({ getParentRoute: () => root, path: "/showcase", component: Showcase });
|
||||
const showcaseApp = createRoute({ getParentRoute: () => root, path: "/showcase/$app", component: ShowcaseApp });
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([home, index, chapter, story, docs, doc]),
|
||||
routeTree: root.addChildren([home, index, chapter, story, docs, doc, showcase, showcaseApp]),
|
||||
trailingSlash: "always",
|
||||
scrollRestoration: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { counts, cover, filtered, sorted, split, SUBMIT, type App, type Catalog, type Sort } from "../apps";
|
||||
import data from "../showcase.json";
|
||||
|
||||
export const catalog = data as Catalog;
|
||||
|
||||
/** The day an app joined, as its manifest wrote it. */
|
||||
export const joined = (app: App) =>
|
||||
new Date(`${app.publishedAt.slice(0, 10)}T00:00:00Z`).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" });
|
||||
|
||||
export const starCount = (n: number) => `${new Intl.NumberFormat("en-US", { notation: "compact" }).format(n)} ${n === 1 ? "star" : "stars"}`;
|
||||
|
||||
function Card({ app }: { app: App }) {
|
||||
return (
|
||||
<li className="app">
|
||||
<Link to="/showcase/$app/" params={{ app: app.id }}>
|
||||
<span className="app-shot">
|
||||
<img src={cover(app)} alt="" loading="lazy" />
|
||||
</span>
|
||||
<span className="app-name">
|
||||
{app.name}
|
||||
{app.building && <span className="app-tag">Building</span>}
|
||||
</span>
|
||||
<span className="app-by">{app.author}</span>
|
||||
<span className="app-text">{app.description}</span>
|
||||
<span className="app-meta">
|
||||
<span>{app.platforms.join(" · ")}</span>
|
||||
{app.stars !== undefined && <span>{starCount(app.stars)}</span>}
|
||||
<span>{joined(app)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Grid({ apps, label }: { apps: App[]; label: string }) {
|
||||
return (
|
||||
<ul className="apps" aria-label={label}>
|
||||
{apps.map((a) => (
|
||||
<Card key={a.id} app={a} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function Submit() {
|
||||
return (
|
||||
<section className="submit">
|
||||
<h2>Built something with Ely?</h2>
|
||||
<p>Add it by pull request to the showcase repository.</p>
|
||||
<a className="button" href={SUBMIT} rel="noopener">
|
||||
Submit your app
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Showcase() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<Sort>("newest");
|
||||
useEffect(() => {
|
||||
document.title = "Showcase · Ely";
|
||||
}, []);
|
||||
const { featured, rest } = split(catalog);
|
||||
const searching = query.trim() !== "" || category !== null;
|
||||
const found = sorted(filtered(catalog.apps, query, category), sort);
|
||||
const empty = catalog.apps.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="showcase">
|
||||
<h1>Showcase.</h1>
|
||||
<p className="lede">Apps built with Ely.</p>
|
||||
{empty ? (
|
||||
<p className="none">No apps yet. Yours could be the first.</p>
|
||||
) : (
|
||||
<>
|
||||
<input className="find" type="search" placeholder={`Search ${catalog.apps.length} apps`} aria-label="Search apps" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Escape" && setQuery("")} />
|
||||
<div className="filters">
|
||||
<div className="chips" role="group" aria-label="Category">
|
||||
<button className="chip" aria-pressed={category === null} onClick={() => setCategory(null)}>
|
||||
All <span>{catalog.apps.length}</span>
|
||||
</button>
|
||||
{counts(catalog).map(([key, label, n]) => (
|
||||
<button key={key} className="chip" aria-pressed={category === key} onClick={() => setCategory(key)}>
|
||||
{label} <span>{n}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="chips" role="group" aria-label="Sort">
|
||||
<button className="chip" aria-pressed={sort === "newest"} onClick={() => setSort("newest")}>
|
||||
Newest
|
||||
</button>
|
||||
<button className="chip" aria-pressed={sort === "stars"} onClick={() => setSort("stars")}>
|
||||
Most stars
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{searching ? (
|
||||
found.length ? <Grid apps={found} label="Matching apps" /> : <p className="none">Nothing matches.</p>
|
||||
) : (
|
||||
<>
|
||||
{featured.length > 0 && (
|
||||
<>
|
||||
<h2 className="apps-title">Featured</h2>
|
||||
<Grid apps={featured} label="Featured apps" />
|
||||
</>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<>
|
||||
<h2 className="apps-title">{featured.length ? "More apps" : "All apps"}</h2>
|
||||
<Grid apps={sorted(rest, sort)} label={featured.length ? "More apps" : "All apps"} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<Submit />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { catalog, joined, starCount, Submit } from "./Showcase";
|
||||
import { NotFound } from "./NotFound";
|
||||
|
||||
export function ShowcaseApp() {
|
||||
const { app: id } = useParams({ strict: false });
|
||||
const app = catalog.apps.find((a) => a.id === id);
|
||||
useEffect(() => {
|
||||
if (app) document.title = `${app.name} · Showcase · Ely`;
|
||||
}, [app]);
|
||||
if (!app) return <NotFound what={`No app named ${id} in the showcase.`} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="app-page">
|
||||
<Link className="crumb" to="/showcase/">
|
||||
← Showcase
|
||||
</Link>
|
||||
<h1>
|
||||
{app.name}
|
||||
{app.building && <span className="app-tag">Building</span>}
|
||||
</h1>
|
||||
<p className="app-by">{app.author}</p>
|
||||
<p className="lede">{app.description}</p>
|
||||
<div className="app-links">
|
||||
{app.website && (
|
||||
<a className="button" href={app.website} rel="noopener">
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
{app.source && (
|
||||
<a className={app.website ? "text" : "button"} href={app.source} rel="noopener">
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<dl className="app-facts">
|
||||
<div>
|
||||
<dt>Category</dt>
|
||||
<dd>{catalog.categories[app.category]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Platforms</dt>
|
||||
<dd>{app.platforms.join(", ")}</dd>
|
||||
</div>
|
||||
{app.stars !== undefined && (
|
||||
<div>
|
||||
<dt>GitHub</dt>
|
||||
<dd>{starCount(app.stars)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Added</dt>
|
||||
<dd>{joined(app)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="app-previews">
|
||||
{app.previews.map((name, i) => (
|
||||
<img key={name} src={`/showcase/${app.id}/${name}`} alt={`${app.name}, screenshot ${i + 1} of ${app.previews.length}`} loading={i ? "lazy" : "eager"} />
|
||||
))}
|
||||
</div>
|
||||
{app.html && <div className="prose app-readme" dangerouslySetInnerHTML={{ __html: app.html }} />}
|
||||
</article>
|
||||
<Submit />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+49
-6
@@ -197,6 +197,41 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.code .copy { position: absolute; top: 10px; right: 12px; padding: 4px 8px; font-size: 12px; color: var(--muted); transition: color 160ms ease-out; }
|
||||
.code .copy:hover { color: var(--fg); }
|
||||
|
||||
.showcase, .app-page { padding: 160px 64px 64px; }
|
||||
.showcase h1 { font-size: clamp(48px, 7vw, 112px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
|
||||
.lede { margin-top: 20px; font-size: 20px; line-height: 30px; max-width: 48ch; }
|
||||
.showcase .none { margin-top: 56px; }
|
||||
.filters { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 32px; margin: -40px 0 56px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip { height: 32px; padding: 0 14px; border: 1px solid var(--line); border-radius: 999px; font-size: 13px; color: var(--muted); transition: color 160ms ease-out, border-color 160ms ease-out; }
|
||||
.chip span { color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
.chip:hover, .chip[aria-pressed="true"] { color: var(--fg); border-color: var(--fg); }
|
||||
.apps-title { margin: 0 0 24px; font-size: 14px; font-weight: 500; letter-spacing: 0; color: var(--muted); }
|
||||
.apps { list-style: none; margin: 0 0 72px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 48px 32px; }
|
||||
.app a { display: grid; gap: 6px; border-radius: 12px; }
|
||||
.app-shot { display: block; aspect-ratio: 16 / 10; margin-bottom: 14px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: var(--code); }
|
||||
.app-shot img { display: block; width: 100%; height: 100%; object-fit: contain; transition: transform 320ms ease-out; }
|
||||
.app a:hover .app-shot img { transform: scale(1.02); }
|
||||
.app-name { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 20px; line-height: 28px; font-weight: 700; letter-spacing: -0.02em; overflow-wrap: anywhere; }
|
||||
.app-tag { padding: 0 8px; border: 1px solid var(--line); border-radius: 999px; font: 400 12px/20px var(--mono); letter-spacing: 0; color: var(--muted); }
|
||||
.app-by { font-size: 14px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.app-page .lede, .app-facts dd { overflow-wrap: anywhere; }
|
||||
.app-text { font-size: 15px; line-height: 24px; color: color-mix(in srgb, var(--fg) 82%, var(--bg)); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
||||
.app-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 4px; font: 400 12px/18px var(--mono); color: var(--faint); }
|
||||
.submit { display: grid; justify-items: start; gap: 20px; margin: 0 64px; padding: 96px 0 128px; border-top: 1px solid var(--line); }
|
||||
.submit h2 { font-size: clamp(32px, 4vw, 56px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; }
|
||||
.app-page { max-width: 1080px; }
|
||||
.app-page h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 8px 0 12px; font-size: clamp(40px, 5vw, 80px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; overflow-wrap: anywhere; }
|
||||
.app-page h1 .app-tag { font-size: 13px; }
|
||||
.app-links { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 32px; }
|
||||
.app-facts { display: flex; flex-wrap: wrap; gap: 24px 48px; margin: 48px 0; }
|
||||
.app-facts dt { font: 400 12px/18px var(--mono); color: var(--faint); }
|
||||
.app-facts dd { margin: 4px 0 0; font-size: 15px; }
|
||||
.app-previews { display: grid; gap: 32px; }
|
||||
.app-previews img, .app-readme img { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.app-readme { max-width: 720px; margin-top: 32px; }
|
||||
.app-readme th, .app-readme td { overflow-wrap: anywhere; }
|
||||
|
||||
.missing { min-height: 70vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 160px 64px; }
|
||||
.missing h1 { font-size: 64px; letter-spacing: -0.04em; }
|
||||
|
||||
@@ -224,8 +259,12 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding: 0 16px; gap: 12px; }
|
||||
.bar nav { gap: 16px; }
|
||||
.bar .end { gap: 0; }
|
||||
.hero, .chapters, .missing { padding-left: 20px; padding-right: 20px; }
|
||||
.hero, .chapters, .missing, .showcase, .app-page { padding-left: 20px; padding-right: 20px; }
|
||||
.showcase, .app-page { padding-top: 112px; }
|
||||
.submit { margin: 0 20px; padding: 72px 0 96px; }
|
||||
.filters { margin-top: -40px; }
|
||||
.reader, .docs { padding: 0 20px; }
|
||||
.chapters li { grid-template-columns: 40px minmax(0, 1fr); }
|
||||
.chapters .titles { grid-column: 2; margin-top: 8px; }
|
||||
@@ -233,7 +272,7 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.chapters .name { font-size: 22px; }
|
||||
.hero-iso { width: 100%; height: 55%; }
|
||||
.hero > :not(.iso) { max-width: none; }
|
||||
.hero h1, .chapters h1 { font-size: 13vw; }
|
||||
.hero h1, .chapters h1, .showcase h1 { font-size: 13vw; }
|
||||
.feature h2, .city h2, .close h2 { font-size: 10vw; }
|
||||
.feature, .city { padding: 112px 20px 48px; }
|
||||
.feature-iso { min-height: 220px; }
|
||||
@@ -243,13 +282,17 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.close { padding: 112px 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.bar { padding: 0 12px; gap: 10px; }
|
||||
.bar nav { gap: 14px; }
|
||||
.bar .tool { width: 32px; }
|
||||
@media (max-width: 413px) {
|
||||
.bar .sound { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.bar { padding: 0 12px; gap: 8px; }
|
||||
.bar nav { gap: 10px; }
|
||||
.bar .link { font-size: 13px; }
|
||||
.bar .tool { width: 30px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
}
|
||||
Reference in new issue
Block a user