mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Merge pull request #10 from Patiun/claude/patiun-rebrand-gjoo9f
Before launch check in admin and an All makes page
This commit is contained in:
@@ -17,6 +17,7 @@ the vault so Obsidian shows them too. Its changes are recorded with the author
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, each sync job's last run and last success, and Library suggestions: series moved to Reading in PersonalReader with no Library item, each with Add to the Library (title, medium, AniList id, state now, published) and Dismiss. |
|
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, each sync job's last run and last success, and Library suggestions: series moved to Reading in PersonalReader with no Library item, each with Add to the Library (title, medium, AniList id, state now, published) and Dismiss. |
|
||||||
| Site pages | Each page of the site with its Page text note (lede, photos, portrait) as thumbnails, **Edit page** or **Add page text**, and links to the notes that fill the page (crafts, games, decks, product lines, settings). |
|
| Site pages | Each page of the site with its Page text note (lede, photos, portrait) as thumbnails, **Edit page** or **Add page text**, and links to the notes that fill the page (crafts, games, decks, product lines, settings). |
|
||||||
|
| Before launch | What is still a placeholder, worked out from the notes so it shrinks as they are fixed: pages showing their built-in wording (no Page text note, a draft one, no lede, no About portrait), notes holding a comment, Coming soon, TODO, TBD or lorem, posts not published, and live notes missing the field their page is built around (a craft's blurb, a make's finish date, a deck's take). Each line links to Edit or Add page text. |
|
||||||
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
|
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
|
||||||
| Notes, Drafts | Browse and search every note, or only the unpublished ones. Each row has Edit; filtered to one type, the list also offers to add a note of that type. |
|
| Notes, Drafts | Browse and search every note, or only the unpublished ones. Each row has Edit; filtered to one type, the list also offers to add a note of that type. |
|
||||||
| A note | A preview of the body, its fields, what links to it, Edit, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
| A note | A preview of the body, its fields, what links to it, Edit, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
||||||
|
|||||||
@@ -138,3 +138,9 @@ form.inline button { min-height: 30px; padding: 2px 12px; font-size: 13px; }
|
|||||||
.page-images img { width: 120px; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 4px; }
|
.page-images img { width: 120px; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 4px; }
|
||||||
.feeds { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
.feeds { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
.pill-link { border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; font-size: 0.85rem; }
|
.pill-link { border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Before launch */
|
||||||
|
.launch ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.launch li { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid var(--line); }
|
||||||
|
.launch li:first-child { border-top: 0; }
|
||||||
|
.count-pill { font-size: 0.8rem; border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px; margin-left: 6px; vertical-align: middle; }
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ export class ContentApi {
|
|||||||
notes = (p: { type?: string; q?: string; limit?: number; full?: boolean } = {}) =>
|
notes = (p: { type?: string; q?: string; limit?: number; full?: boolean } = {}) =>
|
||||||
this.get<Summary[]>("/api/notes", { type: p.type, q: p.q, limit: p.limit, full: p.full ? "true" : undefined });
|
this.get<Summary[]>("/api/notes", { type: p.type, q: p.q, limit: p.limit, full: p.full ? "true" : undefined });
|
||||||
fullNotes = (type: string) => this.get<FullNote[]>("/api/notes", { type, full: "true", limit: 1000 });
|
fullNotes = (type: string) => this.get<FullNote[]>("/api/notes", { type, full: "true", limit: 1000 });
|
||||||
|
/** Every note, whole, a thousand at a time. */
|
||||||
|
async allNotes(): Promise<FullNote[]> {
|
||||||
|
const all: FullNote[] = [];
|
||||||
|
for (let offset = 0; ; offset += 1000) {
|
||||||
|
const page = await this.get<FullNote[]>("/api/notes", { full: "true", limit: 1000, offset });
|
||||||
|
all.push(...page);
|
||||||
|
if (page.length < 1000) return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
note = (path: string) => this.get<Note>("/api/note", { path });
|
note = (path: string) => this.get<Note>("/api/note", { path });
|
||||||
noteByName = (name: string) => this.get<Note>("/api/note", { name });
|
noteByName = (name: string) => this.get<Note>("/api/note", { name });
|
||||||
history = (path: string) => this.get<Revision[]>("/api/note/history", { path });
|
history = (path: string) => this.get<Revision[]>("/api/note/history", { path });
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Before launch: what on the live site is still a placeholder, worked out
|
||||||
|
* from the notes themselves so it stays true as they change. Pages using
|
||||||
|
* their built-in wording, notes that still hold placeholder text, posts not
|
||||||
|
* published yet, and the thin spots the page designs expect to be filled.
|
||||||
|
*/
|
||||||
|
import type { FullNote } from "./client.ts";
|
||||||
|
import { editHref, newHref } from "./editor.ts";
|
||||||
|
import { html, type Html } from "./html.ts";
|
||||||
|
import { sitePages } from "./sitepages.ts";
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
what: string;
|
||||||
|
/** The note to open, or a page to add. */
|
||||||
|
href: string;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
findings: Finding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text that marks something as not written yet. */
|
||||||
|
const PLACEHOLDER = /<!--|\bcoming soon\b|\bplaceholder\b|\blorem\b|\bTODO\b|\bTBD\b/i;
|
||||||
|
/** Pages whose lede or portrait the site fills in when the note has none. */
|
||||||
|
const BUILT_IN = new Set(["/", "/about", "/now", "/blog", "/library", "/library/shelf", "/inquiries", "/tabletop", "/workshop", "/work", "/store", "/404"]);
|
||||||
|
|
||||||
|
const fieldsText = (n: FullNote) => JSON.stringify(n.data);
|
||||||
|
const edit = (n: FullNote) => (n.type === "post" ? `/posts/edit?path=${encodeURIComponent(n.path)}` : editHref(n.path));
|
||||||
|
|
||||||
|
export function launchCheck(notes: FullNote[]): Section[] {
|
||||||
|
const live = notes.filter((n) => n.publish);
|
||||||
|
const of = (type: string, list = live) => list.filter((n) => n.type === type);
|
||||||
|
const pageNotes = of("page", notes);
|
||||||
|
|
||||||
|
const pages: Finding[] = [];
|
||||||
|
for (const p of sitePages) {
|
||||||
|
if (!BUILT_IN.has(p.page)) continue;
|
||||||
|
const n = pageNotes.find((x) => x.data.page === p.page);
|
||||||
|
if (!n) pages.push({ what: `${p.name} (${p.page}) has no Page text note, so it shows its built-in wording.`, href: newHref("page", { page: p.page, title: p.name }), action: "Add page text" });
|
||||||
|
else if (!n.publish) pages.push({ what: `${p.name} (${p.page}): its Page text note is a draft, so the page shows its built-in wording.`, href: edit(n), action: "Edit" });
|
||||||
|
else if (!n.data.lede && p.page !== "/") pages.push({ what: `${p.name} (${p.page}) has no lede, so it shows the built-in one.`, href: edit(n), action: "Edit" });
|
||||||
|
}
|
||||||
|
const about = pageNotes.find((x) => x.data.page === "/about");
|
||||||
|
if (!about?.data.portrait) pages.push({ what: "About has no portrait, so star-chart art stands in.", href: about ? edit(about) : newHref("page", { page: "/about", title: "About" }), action: about ? "Edit" : "Add page text" });
|
||||||
|
|
||||||
|
const text = notes
|
||||||
|
.filter((n) => !n.path.startsWith("_templates/") && (PLACEHOLDER.test(n.body) || PLACEHOLDER.test(fieldsText(n))))
|
||||||
|
.map((n) => ({ what: `${n.title}${n.publish ? "" : " (draft)"}: ${PLACEHOLDER.exec(n.body)?.[0] ?? PLACEHOLDER.exec(fieldsText(n))?.[0]}`, href: edit(n), action: "Edit" }));
|
||||||
|
|
||||||
|
const drafts = of("post", notes)
|
||||||
|
.filter((n) => !n.publish)
|
||||||
|
.map((n) => ({ what: `${n.title}${n.data.postType ? ` (${String(n.data.postType)})` : ""}`, href: edit(n), action: "Finish" }));
|
||||||
|
|
||||||
|
const thin: Finding[] = [
|
||||||
|
...of("craft").filter((n) => !n.data.blurb).map((n) => ({ what: `Craft ${n.title} has no blurb.`, href: edit(n), action: "Edit" })),
|
||||||
|
...of("make").filter((n) => !n.data.date).map((n) => ({ what: `Make ${n.title} has no finish date, so Recently finished can't order it.`, href: edit(n), action: "Edit" })),
|
||||||
|
...of("campaign").filter((n) => !n.data.blurb).map((n) => ({ what: `Campaign ${n.title} has no blurb.`, href: edit(n), action: "Edit" })),
|
||||||
|
...of("deck").filter((n) => !n.data.take).map((n) => ({ what: `Deck ${n.title} has no take.`, href: edit(n), action: "Edit" })),
|
||||||
|
...of("card-game").filter((n) => !n.data.blurb).map((n) => ({ what: `Card game ${n.title} has no blurb.`, href: edit(n), action: "Edit" })),
|
||||||
|
...of("experience").filter((n) => !n.data.focus && !n.data.earlier).map((n) => ({ what: `${n.title} has no focus line.`, href: edit(n), action: "Edit" })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ title: "Pages using built-in wording", hint: "Write the page's text so the site says it in your words.", findings: pages },
|
||||||
|
{ title: "Placeholder text in notes", hint: "Notes holding a comment, Coming soon, TODO or similar. A draft note isn't on the site, but it still needs finishing before it goes live.", findings: text },
|
||||||
|
{ title: "Articles not published", hint: "Drafts on the Blog and Work pages.", findings: drafts },
|
||||||
|
{ title: "Thin spots", hint: "Live notes missing a field their page is designed around.", findings: thin },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchPage(sections: Section[]): Html {
|
||||||
|
const total = sections.reduce((n, s) => n + s.findings.length, 0);
|
||||||
|
return html`<h1>Before launch</h1>
|
||||||
|
<p class="lede">${total ? html`${total} things are still placeholders or unfinished. The list comes from the notes, so it shrinks as you fix them.` : "Nothing left: every page uses your words and nothing holds placeholder text."}</p>
|
||||||
|
${sections.map(
|
||||||
|
(s) => html`<section class="panel launch">
|
||||||
|
<h2>${s.title} <span class="count-pill">${s.findings.length}</span></h2>
|
||||||
|
<p class="hint">${s.hint}</p>
|
||||||
|
${s.findings.length
|
||||||
|
? html`<ul>${s.findings.map((f) => html`<li><span>${f.what}</span> <a href="${f.href}">${f.action}</a></li>`)}</ul>`
|
||||||
|
: html`<p class="muted">All done.</p>`}
|
||||||
|
</section>`,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface Flash {
|
|||||||
const nav: [string, string][] = [
|
const nav: [string, string][] = [
|
||||||
["/", "Overview"],
|
["/", "Overview"],
|
||||||
["/site", "Site pages"],
|
["/site", "Site pages"],
|
||||||
|
["/launch", "Before launch"],
|
||||||
["/review", "Review"],
|
["/review", "Review"],
|
||||||
["/inbox", "Inbox"],
|
["/inbox", "Inbox"],
|
||||||
["/notes", "Notes"],
|
["/notes", "Notes"],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as inbox from "./inbox.ts";
|
|||||||
import * as posts from "./posts.ts";
|
import * as posts from "./posts.ts";
|
||||||
import * as editor from "./editor.ts";
|
import * as editor from "./editor.ts";
|
||||||
import { sitePagesView } from "./sitepages.ts";
|
import { sitePagesView } from "./sitepages.ts";
|
||||||
|
import { launchCheck, launchPage } from "./launch.ts";
|
||||||
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
||||||
|
|
||||||
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
|
||||||
@@ -134,6 +135,8 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
const singles = new Map(all.slice(1).flatMap((l) => (l[0] ? [[l[0].type, l[0]] as const] : [])));
|
const singles = new Map(all.slice(1).flatMap((l) => (l[0] ? [[l[0].type, l[0]] as const] : [])));
|
||||||
return { title: "Site pages", body: sitePagesView(all[0]!, singles) };
|
return { title: "Site pages", body: sitePagesView(all[0]!, singles) };
|
||||||
}
|
}
|
||||||
|
case "/launch":
|
||||||
|
return { title: "Before launch", body: launchPage(launchCheck(await api.allNotes())) };
|
||||||
case "/edit": {
|
case "/edit": {
|
||||||
const note = await api.note(required(q, "path"));
|
const note = await api.note(required(q, "path"));
|
||||||
if (note.type === "post") return new Redirect(posts.editHref(note.path));
|
if (note.type === "post") return new Redirect(posts.editHref(note.path));
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const sitePages: SitePage[] = [
|
|||||||
{ page: "/work", name: "Work", feeds: ["post", "experience"] },
|
{ page: "/work", name: "Work", feeds: ["post", "experience"] },
|
||||||
{ page: "/resume", name: "Resume", feeds: ["experience", "skills", "education", "site-settings"] },
|
{ page: "/resume", name: "Resume", feeds: ["experience", "skills", "education", "site-settings"] },
|
||||||
{ page: "/workshop", name: "Workshop", feeds: ["craft", "make", "station", "tool"] },
|
{ page: "/workshop", name: "Workshop", feeds: ["craft", "make", "station", "tool"] },
|
||||||
|
{ page: "/workshop/makes", name: "All makes", feeds: ["make"] },
|
||||||
{ page: "/library", name: "Library", feeds: ["library-item"] },
|
{ page: "/library", name: "Library", feeds: ["library-item"] },
|
||||||
{ page: "/library/shelf", name: "Shelf", feeds: ["board-game"] },
|
{ page: "/library/shelf", name: "Shelf", feeds: ["board-game"] },
|
||||||
{ page: "/tabletop", name: "Tabletop", feeds: ["tabletop-system", "campaign", "card-game", "deck"] },
|
{ page: "/tabletop", name: "Tabletop", feeds: ["tabletop-system", "campaign", "card-game", "deck"] },
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ describe("admin page", () => {
|
|||||||
expect(page.text).toContain("Suwayomi did not answer");
|
expect(page.text).toContain("Suwayomi did not answer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists what is still a placeholder before launch", async () => {
|
||||||
|
await post("/edit/save", { type: "craft", title: "Leather", status: "Practicing", "has.publish": "1", publish: "1" });
|
||||||
|
const text = (await get("/launch")).text;
|
||||||
|
expect(text).toContain("Before launch");
|
||||||
|
expect(text).toContain("Blog (/blog) has no Page text note");
|
||||||
|
expect(text).toContain("Craft Leather has no blurb.");
|
||||||
|
});
|
||||||
|
|
||||||
it("edits a page's text and images from Site pages", async () => {
|
it("edits a page's text and images from Site pages", async () => {
|
||||||
const site = await get("/site");
|
const site = await get("/site");
|
||||||
expect(site.text).toContain("Add page text");
|
expect(site.text).toContain("Add page text");
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class SiteIndex {
|
|||||||
return `/workshop/${n.slug}`;
|
return `/workshop/${n.slug}`;
|
||||||
case "make": {
|
case "make": {
|
||||||
const craft = this.byName(d.craft as string);
|
const craft = this.byName(d.craft as string);
|
||||||
return craft ? `/workshop/${craft.slug}#${n.slug}` : `/workshop#${n.slug}`;
|
return craft ? `/workshop/${craft.slug}#${n.slug}` : `/workshop/makes#${n.slug}`;
|
||||||
}
|
}
|
||||||
case "station":
|
case "station":
|
||||||
case "tool":
|
case "tool":
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ const nowItems: NowItem[] = statusesFor(s, "/workshop").map((st) => {
|
|||||||
{
|
{
|
||||||
recent.length > 0 && (
|
recent.length > 0 && (
|
||||||
<WorldSection id="ws-recent" title="Recently finished">
|
<WorldSection id="ws-recent" title="Recently finished">
|
||||||
|
<a slot="action" href="/workshop/makes" class="gk-text all">
|
||||||
|
See all {allMakes.length} makes
|
||||||
|
</a>
|
||||||
<ul class="recent">
|
<ul class="recent">
|
||||||
{recent.map((m) => {
|
{recent.map((m) => {
|
||||||
const found = s.byName(m.data.craft);
|
const found = s.byName(m.data.craft);
|
||||||
@@ -406,6 +409,11 @@ const nowItems: NowItem[] = statusesFor(s, "/workshop").map((st) => {
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.all {
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sword rack */
|
/* Sword rack */
|
||||||
.rack {
|
.rack {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
import Chip from "@gk/ui/components/Chip.astro";
|
||||||
|
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
|
||||||
|
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||||
|
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||||
|
import { pageSeals } from "@gk/ui/components/seals.ts";
|
||||||
|
import CtaBand from "../../components/world/CtaBand.astro";
|
||||||
|
import WorldSection from "../../components/world/WorldSection.astro";
|
||||||
|
import Button from "@gk/ui/components/Button.astro";
|
||||||
|
import type { Note } from "../../lib/content.ts";
|
||||||
|
import { plural, site } from "../../lib/page.ts";
|
||||||
|
import { formatDate } from "../../lib/site.ts";
|
||||||
|
import { byDateDesc, byOrder, craftSeal, makePhotos } from "../../lib/world.ts";
|
||||||
|
|
||||||
|
const s = await site();
|
||||||
|
const text = s.pageText("/workshop/makes");
|
||||||
|
const intro = s.render(text?.body);
|
||||||
|
|
||||||
|
const craftOf = (m: Note<"make">) => {
|
||||||
|
const found = s.byName(m.data.craft);
|
||||||
|
return found?.type === "craft" ? (found as Note<"craft">) : undefined;
|
||||||
|
};
|
||||||
|
// Newest first; makes without a finish date follow, still in a stable order.
|
||||||
|
const makes = byDateDesc(s.all("make")).sort((a, b) => Number(!!b.data.date) - Number(!!a.data.date));
|
||||||
|
const crafts = byOrder(s.all("craft")).filter((c) => makes.some((m) => craftOf(m)?.path === c.path));
|
||||||
|
const loose = makes.some((m) => !craftOf(m));
|
||||||
|
const pills =
|
||||||
|
crafts.length + (loose ? 1 : 0) >= 2
|
||||||
|
? [{ label: "All", value: "", active: true }, ...crafts.map((c) => ({ label: c.title, value: c.slug })), ...(loose ? [{ label: "Other", value: "-" }] : [])]
|
||||||
|
: [];
|
||||||
|
---
|
||||||
|
<PageShell title="All makes" family="workshop" current="workshop" description={text?.data.lede ?? "Everything I have made in the Workshop, newest first."}>
|
||||||
|
<Fragment slot="hero">
|
||||||
|
<div class="crumb"><a href="/workshop">Workshop</a> / Makes</div>
|
||||||
|
<h1 class="title">{text?.title ?? "All makes"}</h1>
|
||||||
|
<p class="lede">{text?.data.lede ?? "Everything I have made in the Workshop, newest first."}</p>
|
||||||
|
</Fragment>
|
||||||
|
|
||||||
|
{intro && <div class="gk-prose intro" set:html={intro} />}
|
||||||
|
|
||||||
|
<WorldSection id="mk-all" title="Makes" intro={plural(makes.length, "make")}>
|
||||||
|
{pills.length > 0 && <div slot="action" class="crafts" data-crafts hidden><FilterGroup label="Craft" items={pills} /></div>}
|
||||||
|
{
|
||||||
|
makes.length > 0 ? (
|
||||||
|
<ul class="makes">
|
||||||
|
{makes.map((m) => {
|
||||||
|
const craft = craftOf(m);
|
||||||
|
const photos = makePhotos(m);
|
||||||
|
const log = s.backlinks(m, { type: "post" })[0];
|
||||||
|
return (
|
||||||
|
<li id={m.slug} class="make" data-c={craft?.slug ?? "-"}>
|
||||||
|
<StarChartArt seal={(craft && craftSeal(craft)) ?? pageSeals.workshop} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
|
||||||
|
<span class="m-txt">
|
||||||
|
{craft && (
|
||||||
|
<a href={s.href(craft) ?? undefined} class="m-craft">
|
||||||
|
{craft.title}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<h3 class="m-name">{craft ? <a href={s.href(m) ?? undefined}>{m.title}</a> : m.title}</h3>
|
||||||
|
{m.data.blurb && <span class="m-blurb">{m.data.blurb}</span>}
|
||||||
|
{(m.data.date || log) && (
|
||||||
|
<span class="m-meta">
|
||||||
|
{m.data.date && <time datetime={m.data.date}>{formatDate(m.data.date)}</time>}
|
||||||
|
{log && <Chip href={s.href(log) ?? undefined}>Build log</Chip>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p class="empty">Nothing finished yet.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</WorldSection>
|
||||||
|
|
||||||
|
<CtaBand title="Want one of these?" note="Some of it is in the Store. Anything else can start as an inquiry.">
|
||||||
|
<Button href="/store">Visit the Store</Button>
|
||||||
|
<Button href="/inquiries" variant="secondary">Start an inquiry</Button>
|
||||||
|
</CtaBand>
|
||||||
|
</PageShell>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The craft pills filter the grid; without a script every make shows.
|
||||||
|
const box = document.querySelector<HTMLElement>("[data-crafts]");
|
||||||
|
if (box) {
|
||||||
|
const pills = [...box.querySelectorAll<HTMLButtonElement>("button[data-value]")];
|
||||||
|
const makes = [...document.querySelectorAll<HTMLElement>(".makes > li[data-c]")];
|
||||||
|
box.hidden = false;
|
||||||
|
for (const b of pills) {
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
const v = b.dataset.value ?? "";
|
||||||
|
pills.forEach((p) => p.setAttribute("aria-pressed", String(p === b)));
|
||||||
|
for (const m of makes) m.hidden = !!v && m.dataset.c !== v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.crumb {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gk-gold-bright);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.crumb a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.crumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(34px, 6vw, 48px);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--gk-soft);
|
||||||
|
max-width: 62ch;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
max-width: 70ch;
|
||||||
|
padding-top: 28px;
|
||||||
|
color: var(--gk-soft);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.makes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.make {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--gk-midnight);
|
||||||
|
border: 1px solid var(--gk-lines);
|
||||||
|
border-radius: var(--gk-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
scroll-margin-top: 24px;
|
||||||
|
}
|
||||||
|
.make:target {
|
||||||
|
border-color: var(--gk-gold);
|
||||||
|
}
|
||||||
|
.make :global(.m-art) {
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.m-txt {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.m-craft {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gk-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.m-craft:hover {
|
||||||
|
color: var(--gk-gold-bright);
|
||||||
|
}
|
||||||
|
.m-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.m-name a {
|
||||||
|
color: var(--gk-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.m-name a:hover {
|
||||||
|
color: var(--gk-gold-bright);
|
||||||
|
}
|
||||||
|
.m-blurb {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gk-soft);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.m-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gk-muted);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gk-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user