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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e39a9383d
commit
f281d01f35
17 files changed
+567
-13
No files matched your search
@@ -4,3 +4,5 @@ public/gallery
|
||||
.wrangler
|
||||
test-results
|
||||
playwright-report
|
||||
public/showcase
|
||||
src/showcase.json
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { SUBMIT, type Catalog } from "../src/apps";
|
||||
import data from "../src/showcase.json" with { type: "json" };
|
||||
|
||||
// The catalog the site was built with: the pinned commit, or ELY_SHOWCASE's checkout.
|
||||
const catalog = data as Catalog;
|
||||
const empty = catalog.apps.length === 0;
|
||||
|
||||
function watch(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
test("the header's Showcase link opens the page, with a way to submit", async ({ page }) => {
|
||||
const errors = watch(page);
|
||||
await page.goto("/components/");
|
||||
await page.getByRole("navigation", { name: "Site" }).getByRole("link", { name: "Showcase" }).click();
|
||||
await expect(page).toHaveURL("/showcase/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Showcase.");
|
||||
await expect(page).toHaveTitle("Showcase · Ely");
|
||||
await expect(page.getByRole("link", { name: "Submit your app" })).toHaveAttribute("href", SUBMIT);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an empty catalog says so", async ({ page }) => {
|
||||
test.skip(!empty, "the catalog has apps");
|
||||
await page.goto("/showcase/");
|
||||
await expect(page.locator(".none")).toHaveText("No apps yet. Yours could be the first.");
|
||||
await expect(page.getByRole("searchbox")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("featured apps come first, then the rest, and categories count them", async ({ page }) => {
|
||||
test.skip(empty, "the catalog is empty");
|
||||
await page.goto("/showcase/");
|
||||
const all = page.getByRole("group", { name: "Category" }).getByRole("button");
|
||||
await expect(all.first()).toHaveText(`All ${catalog.apps.length}`);
|
||||
await expect(all).toHaveCount(Object.keys(catalog.categories).length + 1);
|
||||
if (catalog.featured.length) {
|
||||
const first = catalog.apps.find((a) => a.id === catalog.featured[0])!;
|
||||
await expect(page.getByRole("list", { name: "Featured apps" }).locator(".app-name").first()).toContainText(first.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("search narrows the apps, and a miss says so", async ({ page }) => {
|
||||
test.skip(empty, "the catalog is empty");
|
||||
const [app] = catalog.apps;
|
||||
await page.goto("/showcase/");
|
||||
const find = page.getByRole("searchbox", { name: "Search apps" });
|
||||
await find.fill(app.name);
|
||||
await expect(page.getByRole("list", { name: "Matching apps" }).locator(".app-name").first()).toContainText(app.name);
|
||||
await find.fill("zzzz-no-such-app");
|
||||
await expect(page.locator(".none")).toHaveText("Nothing matches.");
|
||||
await find.press("Escape");
|
||||
await expect(find).toHaveValue("");
|
||||
});
|
||||
|
||||
test("a card opens its app, with its previews and links", async ({ page }) => {
|
||||
test.skip(empty, "the catalog is empty");
|
||||
const errors = watch(page);
|
||||
const [app] = catalog.apps;
|
||||
await page.goto("/showcase/");
|
||||
await page.locator(`.app a[href="/showcase/${app.id}/"]`).first().click();
|
||||
await expect(page).toHaveURL(`/showcase/${app.id}/`);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(app.name);
|
||||
const shots = page.locator(".app-previews img");
|
||||
await expect(shots).toHaveCount(app.previews.length);
|
||||
await expect.poll(() => shots.first().evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0);
|
||||
const links = page.locator(".app-links");
|
||||
if (app.source) await expect(links.getByRole("link", { name: "Source" })).toHaveAttribute("href", app.source);
|
||||
if (app.website) await expect(links.getByRole("link", { name: "Website" })).toHaveAttribute("href", app.website);
|
||||
await expect(page.locator(".app-readme")).toHaveCount(app.html ? 1 : 0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an unknown app says so", async ({ page }) => {
|
||||
const errors = watch(page);
|
||||
// Ids are kebab-case, so no catalog holds this one.
|
||||
await page.goto("/showcase/no_such_app/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Not found");
|
||||
expect(errors.join()).toContain("No app named no_such_app");
|
||||
});
|
||||
@@ -4,13 +4,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"gallery": "../scripts/web.sh public/gallery",
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"showcase": "node scripts/showcase.mjs",
|
||||
"dev": "pnpm showcase && vite",
|
||||
"build": "pnpm showcase && tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"local": "ELY_GALLERY_ASSETS=http://localhost:4173/gallery/assets/ pnpm gallery && pnpm build && vite preview --port 4173 --strictPort",
|
||||
"deploy": "wrangler deploy",
|
||||
"e2e": "playwright test",
|
||||
"test": "node --test src/iso/engine.test.ts"
|
||||
"test": "node --test src/iso/engine.test.ts src/apps.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Writes the showcase catalog for the site: the pinned commit, or the checkout ELY_SHOWCASE names.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Marked } from "marked";
|
||||
|
||||
const REPO = "ZacharyZhang-NY/Ely-GPUI-Showcases";
|
||||
const site = fileURLToPath(new URL("..", import.meta.url));
|
||||
const pub = join(site, "public", "showcase");
|
||||
const out = join(site, "src", "showcase.json");
|
||||
const stamp = join(pub, ".commit");
|
||||
|
||||
/** The catalog at `commit`, unpacked with its validator's packages. */
|
||||
async function fetchCatalog(commit) {
|
||||
const work = mkdtempSync(join(tmpdir(), "ely-showcase-"));
|
||||
const url = `https://codeload.github.com/${REPO}/tar.gz/${commit}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`showcase: ${url} answered ${res.status}`);
|
||||
writeFileSync(join(work, "catalog.tar.gz"), Buffer.from(await res.arrayBuffer()));
|
||||
execFileSync("tar", ["-xzf", "catalog.tar.gz"], { cwd: work });
|
||||
const root = join(work, `${REPO.split("/")[1]}-${commit}`);
|
||||
const pnpm = process.env.npm_execpath;
|
||||
if (!pnpm) throw new Error("showcase: run through pnpm, which installs the catalog's validator");
|
||||
execFileSync(process.execPath, [pnpm, "install", "--frozen-lockfile", "--ignore-scripts", "--silent"], { cwd: root, stdio: "inherit" });
|
||||
return { root, done: () => rmSync(work, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
/** Checks the catalog at `root` with its own validator and writes the site's copy. */
|
||||
async function write(root, source) {
|
||||
const { read } = await import(pathToFileURL(join(root, "scripts", "catalog.mjs")).href);
|
||||
const catalog = read(root);
|
||||
rmSync(pub, { recursive: true, force: true });
|
||||
mkdirSync(pub, { recursive: true });
|
||||
const apps = catalog.apps.map(({ readme, ...app }) => {
|
||||
const to = join(pub, app.id);
|
||||
mkdirSync(to, { recursive: true });
|
||||
const pictures = new Set(app.previews);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
walkTokens(token) {
|
||||
if (token.type === "heading") token.depth = Math.min(6, token.depth + 1);
|
||||
if (token.type !== "image") return;
|
||||
pictures.add(token.href);
|
||||
token.href = `/showcase/${app.id}/${token.href}`;
|
||||
},
|
||||
});
|
||||
const html = readme === null ? null : marked.parse(readme, { async: false });
|
||||
for (const name of pictures) copyFileSync(join(root, "apps", app.id, name), join(to, name));
|
||||
return { ...app, html };
|
||||
});
|
||||
writeFileSync(out, `${JSON.stringify({ source, categories: catalog.categories, featured: catalog.featured, apps }, null, 2)}\n`);
|
||||
writeFileSync(stamp, source);
|
||||
console.log(`showcase: ${apps.length} apps, ${catalog.featured.length} featured, from ${source}`);
|
||||
}
|
||||
|
||||
const local = process.env.ELY_SHOWCASE;
|
||||
if (local) {
|
||||
await write(resolve(local), "local");
|
||||
} else {
|
||||
const commit = readFileSync(join(site, "showcase.lock"), "utf8").trim();
|
||||
if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error(`showcase: showcase.lock holds no commit: ${commit}`);
|
||||
if (existsSync(out) && existsSync(stamp) && readFileSync(stamp, "utf8") === commit) {
|
||||
console.log(`showcase: ${commit.slice(0, 7)} already written`);
|
||||
} else {
|
||||
const { root, done } = await fetchCatalog(commit);
|
||||
await write(root, commit);
|
||||
done();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0f8f5b9cc392d4741c239b1c1c0f6071dfc6faa5
|
||||
@@ -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