diff --git a/docs/admin.md b/docs/admin.md index 938d035..1a0e957 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -19,6 +19,8 @@ the vault so Obsidian shows them too. Its changes are recorded with the author | 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. | | A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. | +| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. | +| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. | | Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. | | Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. | | Problems | Whole-folder problems, like links to notes that do not exist. | @@ -34,8 +36,13 @@ the vault so Obsidian shows them too. Its changes are recorded with the author same-origin` (or a matching `Origin`), so a page elsewhere cannot make the browser act on the admin page. - **Drafts cannot run code.** Previews show raw HTML as text and drop links that - are not http, https, mailto or relative. The page sends a content security - policy that allows no scripts at all; it works entirely with plain forms. + are not http, https, mailto or relative. The content security policy allows + only the page's own `/admin.js` (Copy buttons and a confirm before deleting + an image), never inline script, and every page still works with plain forms + when that script does not run. +- **Only images go into the vault.** Uploads are checked by extension and by + their first bytes, SVG is refused, and an image is deleted only when no note + uses it. ## Running it diff --git a/docs/content-service.md b/docs/content-service.md index c504b28..50cd13b 100644 --- a/docs/content-service.md +++ b/docs/content-service.md @@ -96,6 +96,9 @@ roles in `CONTENT_TOKENS`. | `POST /api/held//accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. | | `POST /api/note/rollback` | admin | `{ path, rev }` | | `POST /api/scan` | editor, admin | Compare the whole folder now. | +| `GET /api/attachments` | editor, admin | Every file in `_attachments`, newest first, with its folder, size and the notes whose text names it (`usedBy`). | +| `POST /api/attachments?folder=&name=` | editor, admin | The raw bytes of a PNG, JPEG, WebP, GIF or AVIF image (up to 15 MB; SVG is refused because it can carry script). It is saved in `_attachments/` under a lowercase, hyphenated name, with `-2`, `-3` added when the name is taken anywhere in `_attachments`. Answers `{ name }`. | +| `POST /api/attachments/delete` | editor, admin | `{ name }`. Refused with 409 while any note still uses the file. | | `POST /api/submissions` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. | | `POST /api/submissions//delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. | | `GET /api/submissions?handled=`, `/api/submissions/` | admin | Saved copies, unhandled first, newest first, with counts. | diff --git a/docs/site.md b/docs/site.md index a59cf96..f2b6ba2 100644 --- a/docs/site.md +++ b/docs/site.md @@ -92,7 +92,8 @@ personal Greg Kilmer letterhead, the version without Patiun LLC branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so applicant tracking systems take it in order, aims for one page (roles marked `earlier` print as a short list; skills share a row with education and -projects), uses the brand TTFs in +projects), and when a one-page resume ends well above the footer it is laid +out again with wider gaps (up to twice as wide) so the page reads as full. It uses the brand TTFs in `src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change. The highlights row and Selected projects come from the `/resume` Page text note's `stats` and `projects`. Greg's Resume Interview answers live in @@ -111,6 +112,17 @@ The highlights row and Selected projects come from the `/resume` Page text note' | Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` | | Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` | +## Filters on world pages + +- **Shelf**: How it plays, Players, Play time, Owned, Want to play, Favorites + and Has a blog post, plus four sorts. Players and Play time read the + BoardGameGeek sync and only appear once some game has those values. With no + filter narrowing the list, the first 12 games show and "Show all" opens the + rest. +- **Craft pages**: when a craft's makes carry two or more `tags` (Dice, Rings, + and so on; `sword rack` does not count), pills above the grid filter them. + Makes without a tag fall under Other. + ## Forms The Inquiries form posts to `POST /api/inquiries` and the order form to diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index 2d47c79..bb56d4b 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -399,6 +399,7 @@ export const productLine = def({ channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }), channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(), summary: line({ title: "Summary" }), + menuName: line({ title: "Menu name", description: "A short name for the Store menu, like Proxy cards." }).optional(), icon: line({ title: "Icon" }).optional(), order: z.number().int().meta({ title: "Order" }).optional(), slots: z @@ -501,6 +502,11 @@ export const page = def({ schema: note("page", { page: line({ title: "Page", description: "The page address this text belongs to, like /about." }), lede: line({ title: "Lede", description: "The one line under the page title." }).optional(), + portrait: image({ title: "Portrait", description: "Greg's photo on the landing and About pages." }).optional(), + photos: z + .array(z.object({ file: image({ title: "Image" }), alt: line({ title: "Alt text" }), caption: line({ title: "Caption" }).optional() })) + .meta({ title: "Photos", description: "A row of captioned photos, like Kashi and the tea on About." }) + .optional(), quickFacts: z .array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) })) .meta({ title: "Quick facts" }) diff --git a/packages/ui/src/assets/icons/download.svg b/packages/ui/src/assets/icons/download.svg new file mode 100644 index 0000000..d0b1b29 --- /dev/null +++ b/packages/ui/src/assets/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/motifs/filament-banner.svg b/packages/ui/src/assets/motifs/filament-banner.svg index 5f9de4d..a37ba46 100644 --- a/packages/ui/src/assets/motifs/filament-banner.svg +++ b/packages/ui/src/assets/motifs/filament-banner.svg @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/packages/ui/src/assets/motifs/scorpius.svg b/packages/ui/src/assets/motifs/scorpius.svg new file mode 100644 index 0000000..d500b1f --- /dev/null +++ b/packages/ui/src/assets/motifs/scorpius.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/motifs/seven-metals-dipper.svg b/packages/ui/src/assets/motifs/seven-metals-dipper.svg index 03f556f..006a25d 100644 --- a/packages/ui/src/assets/motifs/seven-metals-dipper.svg +++ b/packages/ui/src/assets/motifs/seven-metals-dipper.svg @@ -1,2 +1,2 @@ -♄♃♂♀☿☽☉ +♄♃♂♀☿☽☉ POLARIS · PLATINUM \ No newline at end of file diff --git a/packages/ui/src/components/ConstellationDust.astro b/packages/ui/src/components/ConstellationDust.astro index cc87226..6f6cb04 100644 --- a/packages/ui/src/components/ConstellationDust.astro +++ b/packages/ui/src/components/ConstellationDust.astro @@ -1,14 +1,21 @@ --- -/** Sparse, static specks behind a page; about one in five gold. */ +/** + * Static specks behind a page; about one in five gold. Two tiles of different + * sizes repeat across the page, so long pages are as starry as short ones and + * the repeat is hard to spot. + */ import { dust, seedOf } from "../lib/ornaments.ts"; interface Props { - count?: number; seed?: string; } -const { count = 48, seed = Astro.url.pathname } = Astro.props; +const { seed = Astro.url.pathname } = Astro.props; +const base = seedOf(seed); --- - + diff --git a/packages/ui/src/components/PageShell.astro b/packages/ui/src/components/PageShell.astro index 84b0b0b..7d71439 100644 --- a/packages/ui/src/components/PageShell.astro +++ b/packages/ui/src/components/PageShell.astro @@ -12,7 +12,7 @@ import ConstellationDust from "./ConstellationDust.astro"; import EdgeSeals from "./EdgeSeals.astro"; import SiteFooter from "./SiteFooter.astro"; import SiteHeader from "./SiteHeader.astro"; -import type { NavKey } from "./nav.ts"; +import type { NavKey, StoreNav } from "./nav.ts"; import type { PageFamily, SealName } from "./seals.ts"; interface Props { @@ -22,10 +22,12 @@ interface Props { /** A craft page's own seal, overriding the family's. */ seal?: SealName; current?: NavKey; + /** The Store's own header, on Store pages. */ + store?: StoreNav; /** Leave the edge seals off, for a page led by a photo. */ noSeals?: boolean; } -const { title, description, family = "landing", seal, current, noSeals } = Astro.props; +const { title, description, family = "landing", seal, current, store, noSeals } = Astro.props; const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`; --- @@ -35,6 +37,10 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`; + + + + {fullTitle} {description && } @@ -44,7 +50,7 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
{!noSeals && } - +
@@ -58,6 +64,11 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`; position: relative; min-height: 100vh; overflow-x: clip; + /* The page's nebula: violet from the top right, teal from the bottom left. */ + background: + radial-gradient(ellipse at 100% 0%, rgba(106, 47, 224, 0.28), transparent 50%), + radial-gradient(ellipse at 0% 100%, rgba(20, 163, 154, 0.2), transparent 55%), + var(--gk-sky); } .gk-main { position: relative; diff --git a/packages/ui/src/components/SiteHeader.astro b/packages/ui/src/components/SiteHeader.astro index 2d53b4f..0336874 100644 --- a/packages/ui/src/components/SiteHeader.astro +++ b/packages/ui/src/components/SiteHeader.astro @@ -2,79 +2,112 @@ /** * The site header: nebula and filament banner behind the primary nav, an * optional page hero (the `hero` slot), and the header divider. The filament - * banner and divider appear here only, never elsewhere on a page. + * banner and divider appear here only, never elsewhere on a page. Store pages + * pass `store` and get the Store's own header: Store.GregKilmer.com, its + * product lines, Order request and a way back to the main site. */ import monogram from "../assets/brand/gk-monogram.svg?url"; import Divider from "./Divider.astro"; import Star from "./Star.astro"; -import { main, tail, world, type NavKey } from "./nav.ts"; +import { main, tail, world, type NavKey, type StoreNav } from "./nav.ts"; import { filamentBanner } from "./seals.ts"; interface Props { /** Which nav entry the page belongs to. */ current?: NavKey; + store?: StoreNav; + /** Whether the page has a hero; PageShell always fills the slot, so it says. */ + hero?: boolean; } -const { current } = Astro.props; +const { current, store, hero = Astro.slots.has("default") } = Astro.props; const inWorld = world.some((w) => w.key === current); const aria = (key: NavKey) => (key === current ? "page" : undefined); ---
- - - + { - Astro.slots.has("default") && ( + store ? ( + + ) : ( + + ) + } + { + hero && (
) } -
+ {hero &&
}
diff --git a/services/site/src/components/library/Constellation.astro b/services/site/src/components/library/Constellation.astro index 2e16eaa..ca712a4 100644 --- a/services/site/src/components/library/Constellation.astro +++ b/services/site/src/components/library/Constellation.astro @@ -1,9 +1,11 @@ --- /** * The favorites constellation: round covers strung on a dotted gold line, - * each linking to its row in the list. + * each linking to its row in the list. A favorite without a cover gets + * star-chart art in its circle. */ import Star from "@gk/ui/components/Star.astro"; +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; import TickScale from "@gk/ui/components/TickScale.astro"; import { mediaSrc } from "../../lib/site.ts"; import type { Item } from "../../lib/library.ts"; @@ -35,7 +37,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif items.map((item, i) => (
  • - + {item.title}
  • @@ -104,18 +106,32 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif color: var(--gk-text); text-decoration: none; } - img { + a :global(.orb) { + flex: none; width: 80px; height: 80px; border-radius: 50%; - object-fit: cover; - background: var(--gk-midnight); box-shadow: 0 0 0 2px var(--gk-gold), 0 0 22px rgba(201, 162, 74, 0.45); transition: box-shadow 0.2s; } - a:hover img { + a :global(.orb .mat) { + display: none; + } + a :global(.orb .seal) { + width: 54px; + height: 54px; + opacity: 0.8; + } + a :global(.orb .photo) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + a:hover :global(.orb) { box-shadow: 0 0 0 2px var(--gk-gold-bright), 0 0 30px rgba(201, 162, 74, 0.7); @@ -137,7 +153,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif li { padding-top: 0; } - img { + a :global(.orb) { width: 64px; height: 64px; } diff --git a/services/site/src/components/library/GameCard.astro b/services/site/src/components/library/GameCard.astro index 07decc6..c81f343 100644 --- a/services/site/src/components/library/GameCard.astro +++ b/services/site/src/components/library/GameCard.astro @@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro"; import StatusPill from "@gk/ui/components/StatusPill.astro"; import StatusChip from "../StatusChip.astro"; import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts"; -import { gamePlayers, gamePlayTime, type Game } from "../../lib/library.ts"; +import { gamePlayers, gamePlayTime, minutesOf, playersOf, type Game } from "../../lib/library.ts"; interface Props { game: Game; @@ -24,6 +24,8 @@ const plays = d.howItPlays ?? []; const status = site.statusFor(game); // Players and play time sync from BoardGameGeek; nothing shows until they have. const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean); +const seats = playersOf(game); +const hasPost = site.backlinks(game, { type: "post" }).length > 0; ---
    - +
    @@ -60,6 +62,18 @@ const SOCIAL_ICONS: Record = { github: "web", linkedin: "briefca )) } + { + photos.length > 0 && ( +
    + {photos.map((ph) => ( +
    + + {ph.caption &&
    {ph.caption}
    } +
    + ))} +
    + ) + }