Merge pull request #7 from Patiun/claude/patiun-rebrand-gjoo9f
Match the site to the designs, and write posts and manage images in admin
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
|
||||||
browser act on the admin page.
|
browser act on the admin page.
|
||||||
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
|
- **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
|
are not http, https, mailto or relative. The content security policy allows
|
||||||
policy that allows no scripts at all; it works entirely with plain forms.
|
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
|
## Running it
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ roles in `CONTENT_TOKENS`.
|
|||||||
| `POST /api/held/<id>/accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. |
|
| `POST /api/held/<id>/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/note/rollback` | admin | `{ path, rev }` |
|
||||||
| `POST /api/scan` | editor, admin | Compare the whole folder now. |
|
| `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/<folder>` 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` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. |
|
||||||
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
|
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
|
||||||
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
|
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
`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.
|
`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
|
The highlights row and Selected projects come from the `/resume` Page text note's
|
||||||
`stats` and `projects`. Greg's Resume Interview answers live in
|
`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` |
|
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
|
||||||
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
|
| 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
|
## Forms
|
||||||
|
|
||||||
The Inquiries form posts to `POST /api/inquiries` and the order form to
|
The Inquiries form posts to `POST /api/inquiries` and the order form to
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ export const productLine = def({
|
|||||||
channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }),
|
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(),
|
channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(),
|
||||||
summary: line({ title: "Summary" }),
|
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(),
|
icon: line({ title: "Icon" }).optional(),
|
||||||
order: z.number().int().meta({ title: "Order" }).optional(),
|
order: z.number().int().meta({ title: "Order" }).optional(),
|
||||||
slots: z
|
slots: z
|
||||||
@@ -501,6 +502,11 @@ export const page = def({
|
|||||||
schema: note("page", {
|
schema: note("page", {
|
||||||
page: line({ title: "Page", description: "The page address this text belongs to, like /about." }),
|
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(),
|
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
|
quickFacts: z
|
||||||
.array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) }))
|
.array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) }))
|
||||||
.meta({ title: "Quick facts" })
|
.meta({ title: "Quick facts" })
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Download"><path d="M12 4v11M7 10.5l5 5 5-5M5 19.5h14"/></svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 400"><line x1="565.0" y1="34.9" x2="578.5" y2="43.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="591.7" y1="55.4" x2="605.3" y2="72.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="582.8" y1="56.8" x2="567.6" y2="96.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="559.7" y1="112.7" x2="557.7" y2="116.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="552.6" y1="132.8" x2="552.2" y2="140.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="553.1" y1="158.4" x2="561.7" y2="216.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="567.7" y1="233.2" x2="580.0" y2="253.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="588.7" y1="269.4" x2="603.1" y2="298.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="601.1" y1="312.7" x2="584.4" y2="330.7" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="570.7" y1="342.2" x2="535.2" y2="365.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="519.1" y1="367.2" x2="497.5" y2="360.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="490.2" y1="348.3" x2="490.0" y2="349.4" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="492.8" y1="331.6" x2="495.0" y2="319.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="505.2" y1="307.9" x2="495.2" y2="311.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><polygon points="557.4,23.0 558.5,28.9 564.4,30.0 558.5,31.1 557.4,37.0 556.3,31.1 550.4,30.0 556.3,28.9" fill="#9A70F5"/><polygon points="586.1,41.4 587.2,47.3 593.1,48.4 587.2,49.6 586.1,55.4 584.9,49.6 579.1,48.4 584.9,47.3" fill="#9A70F5"/><circle cx="611.0" cy="79.1" r="2.4" fill="#F4F2ED"/><circle cx="564.3" cy="104.9" r="2.4" fill="#F4F2ED"/><polygon points="553.1,110.8 555.2,121.7 566.1,123.8 555.2,125.9 553.1,136.8 551.0,125.9 540.1,123.8 551.0,121.7" fill="#C9A24A"/><circle cx="551.8" cy="149.5" r="2.4" fill="#F4F2ED"/><polygon points="563.0,218.5 564.2,224.4 570.0,225.5 564.2,226.7 563.0,232.5 561.9,226.7 556.0,225.5 561.9,224.4" fill="#9A70F5"/><circle cx="584.6" cy="261.3" r="2.4" fill="#F4F2ED"/><circle cx="607.2" cy="306.1" r="2.4" fill="#F4F2ED"/><circle cx="578.3" cy="337.3" r="2.4" fill="#F4F2ED"/><polygon points="527.7,363.0 528.8,368.9 534.7,370.0 528.8,371.1 527.7,377.0 526.6,371.1 520.7,370.0 526.6,368.9" fill="#9A70F5"/><circle cx="489.0" cy="357.2" r="2.4" fill="#F4F2ED"/><circle cx="491.3" cy="340.5" r="2.4" fill="#F4F2ED"/><circle cx="503.8" cy="308.3" r="2.4" fill="#F4F2ED"/><polygon points="496.6,303.6 497.7,309.5 503.6,310.6 497.7,311.7 496.6,317.6 495.5,311.7 489.6,310.6 495.5,309.5" fill="#9A70F5"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
@@ -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";
|
import { dust, seedOf } from "../lib/ornaments.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
count?: number;
|
|
||||||
seed?: string;
|
seed?: string;
|
||||||
}
|
}
|
||||||
const { count = 48, seed = Astro.url.pathname } = Astro.props;
|
const { seed = Astro.url.pathname } = Astro.props;
|
||||||
|
const base = seedOf(seed);
|
||||||
---
|
---
|
||||||
<div class="gk-dust" aria-hidden="true" style={`background:${dust(count, seedOf(seed))}`}></div>
|
<div class="gk-dust" aria-hidden="true">
|
||||||
|
<div class="layer near" style={`background-image:${dust(26, base)}`}></div>
|
||||||
|
<div class="layer far" style={`background-image:${dust(30, base + 7919)}`}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.gk-dust {
|
.gk-dust {
|
||||||
@@ -16,4 +23,16 @@ const { count = 48, seed = Astro.url.pathname } = Astro.props;
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-repeat: repeat;
|
||||||
|
}
|
||||||
|
.near {
|
||||||
|
background-size: 520px 520px;
|
||||||
|
}
|
||||||
|
.far {
|
||||||
|
background-size: 790px 790px;
|
||||||
|
background-position: 213px 157px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import ConstellationDust from "./ConstellationDust.astro";
|
|||||||
import EdgeSeals from "./EdgeSeals.astro";
|
import EdgeSeals from "./EdgeSeals.astro";
|
||||||
import SiteFooter from "./SiteFooter.astro";
|
import SiteFooter from "./SiteFooter.astro";
|
||||||
import SiteHeader from "./SiteHeader.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";
|
import type { PageFamily, SealName } from "./seals.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,10 +22,12 @@ interface Props {
|
|||||||
/** A craft page's own seal, overriding the family's. */
|
/** A craft page's own seal, overriding the family's. */
|
||||||
seal?: SealName;
|
seal?: SealName;
|
||||||
current?: NavKey;
|
current?: NavKey;
|
||||||
|
/** The Store's own header, on Store pages. */
|
||||||
|
store?: StoreNav;
|
||||||
/** Leave the edge seals off, for a page led by a photo. */
|
/** Leave the edge seals off, for a page led by a photo. */
|
||||||
noSeals?: boolean;
|
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`;
|
const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -35,6 +37,10 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#0E1522" />
|
<meta name="theme-color" content="#0E1522" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
{description && <meta name="description" content={description} />}
|
{description && <meta name="description" content={description} />}
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
@@ -44,7 +50,7 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
|||||||
<div class="gk-page">
|
<div class="gk-page">
|
||||||
<ConstellationDust />
|
<ConstellationDust />
|
||||||
{!noSeals && <EdgeSeals family={family} seal={seal} />}
|
{!noSeals && <EdgeSeals family={family} seal={seal} />}
|
||||||
<SiteHeader current={current}><slot name="hero" /></SiteHeader>
|
<SiteHeader current={current} store={store} hero={Astro.slots.has("hero")}><slot name="hero" /></SiteHeader>
|
||||||
<main id="main" class="gk-wrap gk-main">
|
<main id="main" class="gk-wrap gk-main">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
@@ -58,6 +64,11 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: clip;
|
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 {
|
.gk-main {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -2,79 +2,112 @@
|
|||||||
/**
|
/**
|
||||||
* The site header: nebula and filament banner behind the primary nav, an
|
* The site header: nebula and filament banner behind the primary nav, an
|
||||||
* optional page hero (the `hero` slot), and the header divider. The filament
|
* 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 monogram from "../assets/brand/gk-monogram.svg?url";
|
||||||
import Divider from "./Divider.astro";
|
import Divider from "./Divider.astro";
|
||||||
import Star from "./Star.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";
|
import { filamentBanner } from "./seals.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Which nav entry the page belongs to. */
|
/** Which nav entry the page belongs to. */
|
||||||
current?: NavKey;
|
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 inWorld = world.some((w) => w.key === current);
|
||||||
const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||||
---
|
---
|
||||||
<header class="gk-site-header">
|
<header class="gk-site-header">
|
||||||
<div class="nebula" aria-hidden="true"></div>
|
<div class="decor" aria-hidden="true">
|
||||||
<img src={filamentBanner} alt="" aria-hidden="true" class="banner" />
|
<div class="nebula"></div>
|
||||||
<nav aria-label="Primary" class="nav">
|
<img src={filamentBanner} alt="" class="banner" />
|
||||||
<a href="/" class="brand" aria-current={aria("home")}>
|
</div>
|
||||||
<img src={monogram} alt="" width="72" height="72" class="mono" />
|
|
||||||
<span class="words">
|
|
||||||
<span class="name">GREG KILMER</span>
|
|
||||||
<span class="tag">DEVELOPER<Star size={8} tone="teal" />MAKER<Star size={8} tone="teal" />STORYTELLER</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<ul class="links">
|
|
||||||
{main.map((l) => <li><a href={l.href} aria-current={aria(l.key)}>{l.label}</a></li>)}
|
|
||||||
<li class="sep" aria-hidden="true"><Star size={12} /></li>
|
|
||||||
<li class="mw">
|
|
||||||
<a href="/library" class="mw-top" aria-haspopup="true" aria-current={inWorld ? "true" : undefined}>
|
|
||||||
My world
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
|
|
||||||
</a>
|
|
||||||
<ul class="mw-menu">
|
|
||||||
{
|
|
||||||
world.map((w) => (
|
|
||||||
<li>
|
|
||||||
<a href={w.href} aria-current={aria(w.key)}>
|
|
||||||
<span class="mw-name">{w.label}</span>
|
|
||||||
<span class="mw-blurb">{w.blurb}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{tail.map((l) => <li><a href={l.href} aria-current={aria(l.key)} class="dim">{l.label}</a></li>)}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{
|
{
|
||||||
Astro.slots.has("default") && (
|
store ? (
|
||||||
|
<nav aria-label="Store" class="nav">
|
||||||
|
<a href="/store" class="brand" aria-current={store.current === "/store" ? "page" : undefined}>
|
||||||
|
<img src={monogram} alt="" width="72" height="72" class="mono" />
|
||||||
|
<span class="words">
|
||||||
|
<span class="name store-name">Store.GregKilmer.com</span>
|
||||||
|
<span class="store-tag">a Patiun LLC workshop</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ul class="links">
|
||||||
|
{store.links.map((l) => <li><a href={l.href} aria-current={l.href === store.current ? "page" : undefined}>{l.label}</a></li>)}
|
||||||
|
<li class="sep" aria-hidden="true"><Star size={12} /></li>
|
||||||
|
<li><a href="/" class="dim">Back to GregKilmer.com</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
) : (
|
||||||
|
<nav aria-label="Primary" class="nav">
|
||||||
|
<a href="/" class="brand" aria-current={aria("home")}>
|
||||||
|
<img src={monogram} alt="" width="72" height="72" class="mono" />
|
||||||
|
<span class="words">
|
||||||
|
<span class="name">GREG KILMER</span>
|
||||||
|
<span class="tag">DEVELOPER<Star size={8} tone="teal" />MAKER<Star size={8} tone="teal" />STORYTELLER</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ul class="links">
|
||||||
|
{main.map((l) => <li><a href={l.href} aria-current={aria(l.key)}>{l.label}</a></li>)}
|
||||||
|
<li class="sep" aria-hidden="true"><Star size={12} /></li>
|
||||||
|
<li class="mw">
|
||||||
|
<a href="/library" class="mw-top" aria-haspopup="true" aria-current={inWorld ? "true" : undefined}>
|
||||||
|
My world
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</a>
|
||||||
|
<ul class="mw-menu">
|
||||||
|
{
|
||||||
|
world.map((w) => (
|
||||||
|
<li>
|
||||||
|
<a href={w.href} aria-current={aria(w.key)}>
|
||||||
|
<span class="mw-name">{w.label}</span>
|
||||||
|
<span class="mw-blurb">{w.blurb}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{tail.map((l) => <li><a href={l.href} aria-current={aria(l.key)} class="dim">{l.label}</a></li>)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hero && (
|
||||||
<div class="gk-wrap hero">
|
<div class="gk-wrap hero">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<div class="gk-wrap divider"><Divider /></div>
|
{hero && <div class="gk-wrap divider"><Divider /></div>}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The header sits above the page so the My world menu opens over it; only
|
||||||
|
the nebula and banner are clipped to the header. */
|
||||||
.gk-site-header {
|
.gk-site-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
z-index: 10;
|
||||||
color: var(--gk-text);
|
color: var(--gk-text);
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
.decor {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.nebula {
|
.nebula {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.28), transparent 70%),
|
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.28), transparent 70%),
|
||||||
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(20, 163, 154, 0.18), transparent 70%);
|
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(20, 163, 154, 0.18), transparent 70%);
|
||||||
@@ -88,8 +121,6 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
|||||||
width: 900px;
|
width: 900px;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
opacity: 0.22;
|
opacity: 0.22;
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
}
|
||||||
.nav {
|
.nav {
|
||||||
max-width: var(--gk-page-max);
|
max-width: var(--gk-page-max);
|
||||||
@@ -114,6 +145,17 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
|||||||
.mono {
|
.mono {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.brand:hover .mono,
|
||||||
|
.brand:focus-visible .mono {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(216, 179, 90, 0.7)) drop-shadow(0 0 16px rgba(43, 194, 183, 0.45));
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.mono {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.words {
|
.words {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -126,6 +168,15 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
|||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
.store-name {
|
||||||
|
font-variant: small-caps;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
.store-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gk-soft);
|
||||||
|
}
|
||||||
.tag {
|
.tag {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,3 +17,9 @@ export const tail: { key: NavKey; label: string; href: string }[] = [
|
|||||||
{ key: "blog", label: "Blog", href: "/blog" },
|
{ key: "blog", label: "Blog", href: "/blog" },
|
||||||
{ key: "store", label: "Store", href: "/store" },
|
{ key: "store", label: "Store", href: "/store" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** The Store's own header: its pages in order, and which one is open. */
|
||||||
|
export interface StoreNav {
|
||||||
|
links: { label: string; href: string }[];
|
||||||
|
current?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A component's own display never un-hides what a script hid. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,3 +77,36 @@ dl.fields dt { color: var(--muted); }
|
|||||||
dl.fields dd { margin: 0; word-break: break-word; }
|
dl.fields dd { margin: 0; word-break: break-word; }
|
||||||
dl.fields pre { margin: 0; white-space: pre-wrap; }
|
dl.fields pre { margin: 0; white-space: pre-wrap; }
|
||||||
input.num { width: 90px; }
|
input.num { width: 90px; }
|
||||||
|
|
||||||
|
/* Posts and Images */
|
||||||
|
.head-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||||
|
.button.primary { background: var(--teal); border-color: var(--teal); color: var(--midnight); font-weight: 600; }
|
||||||
|
.hint { margin: 4px 0 0; font-size: 13px; color: var(--muted); }
|
||||||
|
.post-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
|
||||||
|
@media (max-width: 900px) { .post-grid { grid-template-columns: 1fr; } }
|
||||||
|
.side .panel h2 { margin-top: 0; font-size: 1.05rem; }
|
||||||
|
.side input[name="links"] { margin-bottom: 8px; }
|
||||||
|
.body-text { font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.55; resize: vertical; min-height: 360px; }
|
||||||
|
.actions.stack { flex-direction: column; align-items: stretch; }
|
||||||
|
.actions.stack button { justify-content: center; }
|
||||||
|
.cover-preview { display: block; width: 100%; max-height: 180px; object-fit: cover; border-radius: 8px; margin-bottom: 6px; }
|
||||||
|
.cover-preview.wide { max-height: 320px; }
|
||||||
|
.post-preview h1 { margin-top: 4px; }
|
||||||
|
details summary { cursor: pointer; color: var(--muted); margin-top: 10px; }
|
||||||
|
.copy-row { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.copy-row input { font-family: ui-monospace, monospace; font-size: 12px; padding: 4px 8px; }
|
||||||
|
.copy-row button { min-height: 30px; padding: 2px 12px; font-size: 13px; }
|
||||||
|
.thumbs { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
|
||||||
|
.thumbs img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 6px; }
|
||||||
|
.folders { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
|
||||||
|
.folders a { padding: 4px 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); text-decoration: none; font-size: 14px; }
|
||||||
|
.folders a[aria-current] { border-color: var(--gold); color: var(--gold-bright); }
|
||||||
|
.images { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||||
|
.images li { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.images img, .images .file { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; background: var(--sky); }
|
||||||
|
.images .file { display: flex; align-items: center; justify-content: center; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.images .meta { display: flex; flex-direction: column; gap: 6px; font-size: 14px; word-break: break-all; }
|
||||||
|
.images .used { font-size: 13px; }
|
||||||
|
form.inline { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }
|
||||||
|
form.inline button { min-height: 30px; padding: 2px 12px; font-size: 13px; }
|
||||||
|
.preview img { max-height: 480px; object-fit: contain; }
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// The admin page's only script, and it is optional: every page works without
|
||||||
|
// it. It adds Copy buttons for embed lines and asks before a delete.
|
||||||
|
for (const b of document.querySelectorAll("button.copy[data-copy]")) {
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
const text = b.getAttribute("data-copy") || "";
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
const input = b.previousElementSibling;
|
||||||
|
if (input && "select" in input) {
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const was = b.textContent;
|
||||||
|
b.textContent = "Copied";
|
||||||
|
setTimeout(() => (b.textContent = was), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const f of document.querySelectorAll("form[action='/images/delete']")) {
|
||||||
|
f.addEventListener("submit", (e) => {
|
||||||
|
const name = f.querySelector("input[name=name]")?.value ?? "this image";
|
||||||
|
if (!confirm(`Delete ${name} from the vault? This cannot be undone.`)) e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -117,6 +117,15 @@ export interface Suggestion {
|
|||||||
cover?: string;
|
cover?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A file in the vault's `_attachments`, with the notes that use it. */
|
||||||
|
export interface Attachment {
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
size: number;
|
||||||
|
modified: string;
|
||||||
|
usedBy: { path: string; title: string; type: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
|
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
|
||||||
|
|
||||||
export class ContentApi {
|
export class ContentApi {
|
||||||
@@ -172,6 +181,20 @@ export class ContentApi {
|
|||||||
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
||||||
dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {});
|
dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {});
|
||||||
|
|
||||||
|
attachments = () => this.get<Attachment[]>("/api/attachments");
|
||||||
|
/** Add an image to `_attachments/<folder>`; answers with the name it was saved under. */
|
||||||
|
async upload(folder: string, name: string, bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
||||||
|
const res = await fetch(new URL(`/api/attachments?${new URLSearchParams({ folder, name })}`, this.base), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/octet-stream" },
|
||||||
|
body: bytes,
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => null)) as { name?: string; error?: string } | null;
|
||||||
|
if (!res.ok || !data?.name) throw new ApiError(res.status, data?.error ?? `The content service answered ${res.status}`, data);
|
||||||
|
return data.name;
|
||||||
|
}
|
||||||
|
deleteAttachment = (name: string) => this.call<{ ok: boolean }>("POST", "/api/attachments/delete", { name });
|
||||||
|
|
||||||
/** Fetch an attachment to pass through to the browser. */
|
/** Fetch an attachment to pass through to the browser. */
|
||||||
attachment = (name: string) =>
|
attachment = (name: string) =>
|
||||||
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const nav: [string, string][] = [
|
|||||||
["/inbox", "Inbox"],
|
["/inbox", "Inbox"],
|
||||||
["/notes", "Notes"],
|
["/notes", "Notes"],
|
||||||
["/drafts", "Drafts"],
|
["/drafts", "Drafts"],
|
||||||
|
["/posts", "Posts"],
|
||||||
|
["/images", "Images"],
|
||||||
["/capture", "Capture"],
|
["/capture", "Capture"],
|
||||||
["/store", "Store"],
|
["/store", "Store"],
|
||||||
["/problems", "Problems"],
|
["/problems", "Problems"],
|
||||||
@@ -29,6 +31,7 @@ export function layout(p: { title: string; path: string; held: number; inbox?: n
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600&family=Montserrat:wght@400;600;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@600&family=Montserrat:wght@400;600;700&display=swap">
|
||||||
<link rel="stylesheet" href="/admin.css">
|
<link rel="stylesheet" href="/admin.css">
|
||||||
|
<script src="/admin.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top">
|
<header class="top">
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Writing blog posts and managing images. A post is a `post` note written
|
||||||
|
* through the content service like any other change; images are files in the
|
||||||
|
* vault's `_attachments`, so Obsidian sees both.
|
||||||
|
*/
|
||||||
|
import { topics } from "@gk/schema";
|
||||||
|
import type { Attachment, Note, Summary } from "./client.ts";
|
||||||
|
import { html, qs, when, type Html } from "./html.ts";
|
||||||
|
import { noteHref, preview } from "./pages.ts";
|
||||||
|
|
||||||
|
/** What the post form holds, from a note or from a posted form. */
|
||||||
|
export interface PostDraft {
|
||||||
|
path?: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
topic: string;
|
||||||
|
postType: string;
|
||||||
|
summary: string;
|
||||||
|
cover: string;
|
||||||
|
links: string[];
|
||||||
|
pinned: boolean;
|
||||||
|
role: string;
|
||||||
|
employer: string;
|
||||||
|
period: string;
|
||||||
|
body: string;
|
||||||
|
publish: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const postTypes = ["article", "build log", "case study"] as const;
|
||||||
|
|
||||||
|
/** Folders under `_attachments` the upload form offers; Posts comes first. */
|
||||||
|
export const imageFolders = ["Posts", "Pages", "Workshop", "Library", "Tabletop", "Store"] as const;
|
||||||
|
|
||||||
|
export const editHref = (path: string) => `/posts/edit${qs({ path })}`;
|
||||||
|
export const unlink = (v: unknown) => String(v ?? "").trim().replace(/^!?\[\[|\]\]$/g, "").split("|")[0]!.trim();
|
||||||
|
|
||||||
|
export function emptyPost(today: string): PostDraft {
|
||||||
|
return { title: "", date: today, topic: topics[0], postType: "article", summary: "", cover: "", links: [], pinned: false, role: "", employer: "", period: "", body: "", publish: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftFromNote(n: Note): PostDraft {
|
||||||
|
const d = n.data;
|
||||||
|
return {
|
||||||
|
path: n.path,
|
||||||
|
title: n.title,
|
||||||
|
date: String(d.date ?? ""),
|
||||||
|
topic: String(d.topic ?? topics[0]),
|
||||||
|
postType: String(d.postType ?? "article"),
|
||||||
|
summary: String(d.summary ?? ""),
|
||||||
|
cover: unlink(d.cover),
|
||||||
|
links: Array.isArray(d.links) ? d.links.map(unlink).filter(Boolean) : [],
|
||||||
|
pinned: d.pinned === true,
|
||||||
|
role: String(d.role ?? ""),
|
||||||
|
employer: String(d.employer ?? ""),
|
||||||
|
period: String(d.period ?? ""),
|
||||||
|
body: n.body,
|
||||||
|
publish: n.publish,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftFromForm(f: URLSearchParams): PostDraft {
|
||||||
|
const t = (k: string) => (f.get(k) ?? "").trim();
|
||||||
|
return {
|
||||||
|
path: t("path") || undefined,
|
||||||
|
title: t("title"),
|
||||||
|
date: t("date"),
|
||||||
|
topic: t("topic"),
|
||||||
|
postType: t("postType") || "article",
|
||||||
|
summary: t("summary"),
|
||||||
|
cover: unlink(t("cover")),
|
||||||
|
links: f
|
||||||
|
.getAll("links")
|
||||||
|
.flatMap((v) => v.split("\n"))
|
||||||
|
.map(unlink)
|
||||||
|
.filter(Boolean),
|
||||||
|
pinned: f.get("pinned") === "1",
|
||||||
|
role: t("role"),
|
||||||
|
employer: t("employer"),
|
||||||
|
period: t("period"),
|
||||||
|
body: (f.get("body") ?? "").replace(/\r\n/g, "\n"),
|
||||||
|
publish: f.get("wasLive") === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note fields for a draft. On an update, an emptied optional field is
|
||||||
|
* sent as null so the content service drops it from the note.
|
||||||
|
*/
|
||||||
|
export function postFields(d: PostDraft, publish: boolean, update: boolean): Record<string, unknown> {
|
||||||
|
const opt = (v: unknown, keep: boolean) => (keep ? v : update ? null : undefined);
|
||||||
|
const caseStudy = d.postType === "case study";
|
||||||
|
const fields: Record<string, unknown> = {
|
||||||
|
title: d.title,
|
||||||
|
date: d.date,
|
||||||
|
topic: d.topic,
|
||||||
|
postType: d.postType,
|
||||||
|
summary: d.summary,
|
||||||
|
publish,
|
||||||
|
cover: opt(`[[${d.cover}]]`, !!d.cover),
|
||||||
|
links: opt(
|
||||||
|
d.links.map((l) => `[[${l}]]`),
|
||||||
|
d.links.length > 0,
|
||||||
|
),
|
||||||
|
pinned: opt(true, d.pinned),
|
||||||
|
role: opt(d.role, caseStudy && !!d.role),
|
||||||
|
employer: opt(d.employer, caseStudy && !!d.employer),
|
||||||
|
period: opt(d.period, caseStudy && !!d.period),
|
||||||
|
};
|
||||||
|
for (const k of Object.keys(fields)) if (fields[k] === undefined) delete fields[k];
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSrc = (name: string) => `/attachments/${encodeURIComponent(name)}`;
|
||||||
|
const kb = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1000))} KB`);
|
||||||
|
const isImage = (name: string) => /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(name);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ posts list
|
||||||
|
|
||||||
|
export function postsList(posts: Summary[]): Html {
|
||||||
|
const sorted = [...posts].sort((a, b) => Number(a.publish) - Number(b.publish) || b.acceptedAt.localeCompare(a.acceptedAt));
|
||||||
|
return html`<div class="head-row"><h1>Posts</h1><a class="button primary" href="/posts/new">Write a post</a></div>
|
||||||
|
<p class="lede">Every blog post, drafts first. A post saved here is a note in the vault's Posts folder, so you can also finish it in Obsidian.</p>
|
||||||
|
${sorted.length
|
||||||
|
? html`<table class="list">
|
||||||
|
<thead><tr><th>Post</th><th>State</th><th>Changed</th><th></th></tr></thead>
|
||||||
|
<tbody>${sorted.map(
|
||||||
|
(p) => html`<tr>
|
||||||
|
<td><a href="${editHref(p.path)}">${p.title}</a></td>
|
||||||
|
<td>${p.publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`}</td>
|
||||||
|
<td class="muted">${when(p.acceptedAt)}</td>
|
||||||
|
<td><a href="${noteHref(p.path)}">Note and history</a></td>
|
||||||
|
</tr>`,
|
||||||
|
)}</tbody></table>`
|
||||||
|
: html`<p class="muted">No posts yet.</p>`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ editor
|
||||||
|
|
||||||
|
export function postEditor(d: PostDraft, opts: { images: Attachment[]; notes: string[]; showPreview?: boolean }): Html {
|
||||||
|
const images = opts.images.filter((a) => isImage(a.name));
|
||||||
|
const linkRows = [...d.links, "", "", ""].slice(0, Math.max(3, d.links.length + 1));
|
||||||
|
return html`<h1>${d.path ? html`Edit post` : html`Write a post`}</h1>
|
||||||
|
<p class="lede">${d.path
|
||||||
|
? html`${d.publish ? html`<span class="pill live">Live</span>` : html`<span class="pill draft">Draft</span>`} Saved as <code>${d.path}</code>.`
|
||||||
|
: html`Write in Markdown. Preview it, save it as a draft, or publish it to the Blog.`}</p>
|
||||||
|
<form method="post" action="/posts/save" enctype="multipart/form-data" class="post-form">
|
||||||
|
${d.path ? html`<input type="hidden" name="path" value="${d.path}">` : ""}
|
||||||
|
${d.publish ? html`<input type="hidden" name="wasLive" value="1">` : ""}
|
||||||
|
<div class="post-grid">
|
||||||
|
<div class="panel">
|
||||||
|
<label for="p-title">Title</label>
|
||||||
|
<input id="p-title" name="title" value="${d.title}" required ${d.path ? html`readonly aria-describedby="p-title-note"` : ""}>
|
||||||
|
${d.path ? html`<p class="hint" id="p-title-note">The title is the note's file name; rename it in Obsidian.</p>` : ""}
|
||||||
|
<div class="row">
|
||||||
|
<div><label for="p-topic">Topic</label><select id="p-topic" name="topic">${topics.map((t) => html`<option ${t === d.topic ? html`selected` : ""}>${t}</option>`)}</select></div>
|
||||||
|
<div><label for="p-type">Kind of post</label><select id="p-type" name="postType">${postTypes.map((t) => html`<option ${t === d.postType ? html`selected` : ""}>${t}</option>`)}</select></div>
|
||||||
|
<div><label for="p-date">Publish date</label><input id="p-date" name="date" type="date" value="${d.date}" required></div>
|
||||||
|
</div>
|
||||||
|
<label for="p-summary">Summary</label>
|
||||||
|
<input id="p-summary" name="summary" value="${d.summary}" required placeholder="One or two sentences for the Blog list and link previews">
|
||||||
|
<label for="p-body">Post</label>
|
||||||
|
<textarea id="p-body" name="body" rows="22" class="body-text" placeholder="Write in Markdown. Embed a vault image with ![[file.jpg]] and link a page with [[Note name]].">${d.body}</textarea>
|
||||||
|
<label for="p-add">Add images to the post</label>
|
||||||
|
<input id="p-add" name="bodyImages" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple>
|
||||||
|
<p class="hint">They are saved to the vault's Posts images and added to the end of the post; move the lines where you want them.</p>
|
||||||
|
</div>
|
||||||
|
<div class="side">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Cover image</h2>
|
||||||
|
${d.cover ? html`<img class="cover-preview" src="${imageSrc(d.cover)}" alt="">` : ""}
|
||||||
|
<label for="p-cover">Pick from the vault</label>
|
||||||
|
<select id="p-cover" name="cover">
|
||||||
|
<option value="">No cover</option>
|
||||||
|
${images.map((a) => html`<option value="${a.name}" ${a.name === d.cover ? html`selected` : ""}>${a.folder ? `${a.folder} / ` : ""}${a.name}</option>`)}
|
||||||
|
</select>
|
||||||
|
<label for="p-cover-file">Or upload one</label>
|
||||||
|
<input id="p-cover-file" name="coverFile" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif">
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Pages it is about</h2>
|
||||||
|
<p class="hint">Library items, crafts, makes, games: the site links the post from each page and back.</p>
|
||||||
|
${linkRows.map((l, i) => html`<input name="links" list="note-names" value="${l}" aria-label="Page ${i + 1}" placeholder="A note's name">`)}
|
||||||
|
<datalist id="note-names">${opts.notes.map((n) => html`<option value="${n}">`)}</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<label class="check"><input type="checkbox" name="pinned" value="1" ${d.pinned ? html`checked` : ""}> Feature it at the top of the Blog</label>
|
||||||
|
<details ${d.postType === "case study" ? html`open` : ""}>
|
||||||
|
<summary>Case study details</summary>
|
||||||
|
<label for="p-role">Role</label><input id="p-role" name="role" value="${d.role}">
|
||||||
|
<label for="p-employer">Employer</label><input id="p-employer" name="employer" value="${d.employer}">
|
||||||
|
<label for="p-period">Period</label><input id="p-period" name="period" value="${d.period}" placeholder="2021 to now">
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="panel actions stack">
|
||||||
|
<button name="action" value="preview">Preview</button>
|
||||||
|
<button name="action" value="draft">${d.publish ? "Save and take off the site" : "Save draft"}</button>
|
||||||
|
<button name="action" value="publish" class="primary">${d.publish ? "Save" : "Publish"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
${opts.showPreview
|
||||||
|
? html`<h2 id="preview">Preview</h2>
|
||||||
|
<div class="panel post-preview">
|
||||||
|
${d.cover ? html`<img class="cover-preview wide" src="${imageSrc(d.cover)}" alt="">` : ""}
|
||||||
|
<p class="muted">${d.topic} · ${d.date}</p>
|
||||||
|
<h1>${d.title || "Untitled"}</h1>
|
||||||
|
${d.summary ? html`<p class="lede">${d.summary}</p>` : ""}
|
||||||
|
${preview(d.body)}
|
||||||
|
</div>`
|
||||||
|
: ""}
|
||||||
|
${imageStrip(images)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest vault images under the editor, each with its embed line to copy. */
|
||||||
|
function imageStrip(images: Attachment[]): Html {
|
||||||
|
if (!images.length) return html``;
|
||||||
|
return html`<h2>Images in the vault</h2>
|
||||||
|
<p class="hint">Copy a line into the post to show that image. <a href="/images">Manage images</a></p>
|
||||||
|
<ul class="thumbs">${images.slice(0, 24).map(
|
||||||
|
(a) => html`<li><img src="${imageSrc(a.name)}" alt="" loading="lazy"><span class="copy-row"><input readonly value="![[${a.name}]]" aria-label="Embed line for ${a.name}"><button type="button" class="copy" data-copy="![[${a.name}]]">Copy</button></span></li>`,
|
||||||
|
)}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ images
|
||||||
|
|
||||||
|
export function imagesPage(list: Attachment[], folder: string): Html {
|
||||||
|
const folders = [...new Set(list.map((a) => a.folder))].sort((a, b) => a.localeCompare(b));
|
||||||
|
const shown = folder ? list.filter((a) => a.folder === folder) : list;
|
||||||
|
return html`<h1>Images</h1>
|
||||||
|
<p class="lede">Everything in the vault's <code>_attachments</code>, newest first. Images added here land in the vault, so Obsidian shows them too.</p>
|
||||||
|
<form class="panel" method="post" action="/images/upload" enctype="multipart/form-data">
|
||||||
|
<h2>Add images</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div><label for="i-files">Images</label><input id="i-files" name="files" type="file" multiple required accept="image/png,image/jpeg,image/webp,image/gif,image/avif"></div>
|
||||||
|
<div><label for="i-folder">Folder</label><select id="i-folder" name="folder">${imageFolders.map((f) => html`<option ${f === (folder || "Posts") ? html`selected` : ""}>${f}</option>`)}</select></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">PNG, JPEG, WebP, GIF or AVIF, up to 15 MB each. Names are made lowercase with hyphens, and a name already in the vault gets a number.</p>
|
||||||
|
<div class="actions"><button class="primary">Upload</button></div>
|
||||||
|
</form>
|
||||||
|
<nav class="folders" aria-label="Folder">
|
||||||
|
<a href="/images" ${!folder ? html`aria-current="page"` : ""}>All (${list.length})</a>
|
||||||
|
${folders.map((f) => html`<a href="/images${qs({ folder: f || "-" })}" ${f === folder ? html`aria-current="page"` : ""}>${f || "Top level"} (${list.filter((a) => a.folder === f).length})</a>`)}
|
||||||
|
</nav>
|
||||||
|
${shown.length
|
||||||
|
? html`<ul class="images">${shown.map(
|
||||||
|
(a) => html`<li class="panel">
|
||||||
|
${isImage(a.name) ? html`<a href="${imageSrc(a.name)}"><img src="${imageSrc(a.name)}" alt="" loading="lazy"></a>` : html`<div class="file">${a.name.split(".").pop()}</div>`}
|
||||||
|
<div class="meta">
|
||||||
|
<strong>${a.name}</strong>
|
||||||
|
<span class="muted">${a.folder || "Top level"} · ${kb(a.size)} · ${when(a.modified)}</span>
|
||||||
|
<span class="copy-row"><input readonly value="![[${a.name}]]" aria-label="Embed line for ${a.name}"><button type="button" class="copy" data-copy="![[${a.name}]]">Copy</button></span>
|
||||||
|
${a.usedBy.length
|
||||||
|
? html`<span class="used">Used by ${a.usedBy.map((u, i) => html`${i ? ", " : ""}<a href="${noteHref(u.path)}">${u.title}</a>`)}</span>`
|
||||||
|
: html`<form method="post" action="/images/delete" class="inline"><input type="hidden" name="name" value="${a.name}"><input type="hidden" name="folder" value="${folder}"><span class="muted">Not used by any note.</span> <button class="danger">Delete</button></form>`}
|
||||||
|
</div>
|
||||||
|
</li>`,
|
||||||
|
)}</ul>`
|
||||||
|
: html`<p class="muted">No images here yet.</p>`}`;
|
||||||
|
}
|
||||||
@@ -16,15 +16,21 @@ import { html, qs, type Html } from "./html.ts";
|
|||||||
import { layout, type Flash } from "./layout.ts";
|
import { layout, type Flash } from "./layout.ts";
|
||||||
import * as pages from "./pages.ts";
|
import * as pages from "./pages.ts";
|
||||||
import * as inbox from "./inbox.ts";
|
import * as inbox from "./inbox.ts";
|
||||||
|
import * as posts from "./posts.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");
|
||||||
|
const js = readFileSync(new URL("../public/admin.js", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
/** The largest form with files: a few photos at a time. */
|
||||||
|
const MAX_UPLOAD = 60_000_000;
|
||||||
|
|
||||||
class Redirect {
|
class Redirect {
|
||||||
constructor(readonly to: string) {}
|
constructor(readonly to: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Form = URLSearchParams;
|
type Form = URLSearchParams;
|
||||||
|
type Upload = { field: string; name: string; bytes: Uint8Array<ArrayBuffer> };
|
||||||
type Page = { title: string; body: Html };
|
type Page = { title: string; body: Html };
|
||||||
|
|
||||||
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
|
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
|
||||||
@@ -112,6 +118,19 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
}
|
}
|
||||||
case "/problems":
|
case "/problems":
|
||||||
return { title: "Problems", body: pages.problemsView(await api.problems()) };
|
return { title: "Problems", body: pages.problemsView(await api.problems()) };
|
||||||
|
case "/posts":
|
||||||
|
return { title: "Posts", body: posts.postsList(await api.notes({ type: "post", limit: 1000 })) };
|
||||||
|
case "/posts/new":
|
||||||
|
return editorPage(posts.emptyPost(today()));
|
||||||
|
case "/posts/edit": {
|
||||||
|
const note = await api.note(required(q, "path"));
|
||||||
|
if (note.type !== "post") return new Redirect(pages.noteHref(note.path));
|
||||||
|
return editorPage(posts.draftFromNote(note));
|
||||||
|
}
|
||||||
|
case "/images": {
|
||||||
|
const folder = q.get("folder") ?? "";
|
||||||
|
return { title: "Images", body: posts.imagesPage(await api.attachments(), folder === "-" ? "" : folder) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (path === "/inbox") {
|
if (path === "/inbox") {
|
||||||
const show = q.get("show") ?? "open";
|
const show = q.get("show") ?? "open";
|
||||||
@@ -131,8 +150,53 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
throw new ApiError(404, "There is no page here.", null);
|
throw new ApiError(404, "There is no page here.", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(path: string, f: Form): Promise<Redirect> {
|
async function editorPage(d: posts.PostDraft, showPreview = false): Promise<Page> {
|
||||||
|
const [images, notes] = await Promise.all([api.attachments(), api.notes({ limit: 5000 })]);
|
||||||
|
const names = [...new Set(notes.filter((n) => n.type !== "post" && n.type !== "status").map((n) => n.title))].sort((a, b) => a.localeCompare(b));
|
||||||
|
return { title: d.title || "Write a post", body: posts.postEditor(d, { images, notes: names, showPreview }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the post form: upload its images, then preview, save a draft or publish. */
|
||||||
|
async function savePost(f: Form, files: Upload[]): Promise<Page | Redirect> {
|
||||||
|
const d = posts.draftFromForm(f);
|
||||||
|
const cover = files.find((u) => u.field === "coverFile");
|
||||||
|
if (cover) d.cover = await api.upload("Posts", cover.name, cover.bytes);
|
||||||
|
for (const u of files.filter((x) => x.field === "bodyImages")) {
|
||||||
|
const name = await api.upload("Posts", u.name, u.bytes);
|
||||||
|
d.body = `${d.body.trimEnd()}\n\n![[${name}]]\n`;
|
||||||
|
}
|
||||||
|
const action = f.get("action") ?? "draft";
|
||||||
|
if (action === "preview") return editorPage(d, true);
|
||||||
|
if (!d.title) throw new ApiError(400, "A post needs a title", null);
|
||||||
|
const publish = action === "publish";
|
||||||
|
const o = d.path
|
||||||
|
? await api.apply({ op: "update", path: d.path, fields: posts.postFields(d, publish, true), body: d.body })
|
||||||
|
: await api.apply({ op: "create", data: { type: "post", ...posts.postFields(d, publish, false) }, body: d.body });
|
||||||
|
const saved = publish ? (d.publish ? "Saved. The live post is updated." : "Published. It is on the Blog now.") : d.publish ? "Taken off the site and saved as a draft." : "Draft saved.";
|
||||||
|
if (o.status === "rejected") {
|
||||||
|
// Keep what was typed: show the form again with the reasons.
|
||||||
|
const page = await editorPage(d);
|
||||||
|
return { ...page, body: html`<div class="flash err" role="alert">Not saved.<ul>${o.reasons.map((r) => html`<li>${r.message}</li>`)}</ul></div>${page.body}` };
|
||||||
|
}
|
||||||
|
return after(posts.editHref(o.path), o, saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path: string, f: Form, files: Upload[] = []): Promise<Redirect | Page> {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
|
case "/posts/save":
|
||||||
|
return savePost(f, files);
|
||||||
|
case "/images/upload": {
|
||||||
|
const folder = f.get("folder") || "Posts";
|
||||||
|
const added: string[] = [];
|
||||||
|
for (const u of files.filter((x) => x.field === "files")) added.push(await api.upload(folder, u.name, u.bytes));
|
||||||
|
if (!added.length) throw new ApiError(400, "Choose at least one image", null);
|
||||||
|
return new Redirect(`/images${qs({ folder, msg: `Added ${added.join(", ")}.` })}`);
|
||||||
|
}
|
||||||
|
case "/images/delete": {
|
||||||
|
const name = required(f, "name");
|
||||||
|
await api.deleteAttachment(name);
|
||||||
|
return new Redirect(`/images${qs({ folder: f.get("folder") || undefined, msg: `Deleted ${name}.` })}`);
|
||||||
|
}
|
||||||
case "/scan": {
|
case "/scan": {
|
||||||
const r = await api.scan();
|
const r = await api.scan();
|
||||||
return new Redirect(`/${qs({ msg: `Scanned: ${r.changed} changed, ${r.held} held.` })}`);
|
return new Redirect(`/${qs({ msg: `Scanned: ${r.changed} changed, ${r.held} held.` })}`);
|
||||||
@@ -258,13 +322,17 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
res.setHeader("referrer-policy", "same-origin");
|
res.setHeader("referrer-policy", "same-origin");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"content-security-policy",
|
"content-security-policy",
|
||||||
"default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'none'; form-action 'self'; frame-ancestors 'none'",
|
"default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'; form-action 'self'; frame-ancestors 'none'",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (url.pathname === "/admin.css") {
|
if (url.pathname === "/admin.css") {
|
||||||
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
|
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
|
||||||
return res.end(css);
|
return res.end(css);
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/admin.js") {
|
||||||
|
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "max-age=300" });
|
||||||
|
return res.end(js);
|
||||||
|
}
|
||||||
if (url.pathname === "/health") {
|
if (url.pathname === "/health") {
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
return res.end('{"ok":true}');
|
return res.end('{"ok":true}');
|
||||||
@@ -296,10 +364,14 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
|||||||
|
|
||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
||||||
const form = new URLSearchParams(await readBody(req));
|
const { form, files } = await readForm(req);
|
||||||
const r = await post(url.pathname, form);
|
const r = await post(url.pathname, form, files);
|
||||||
res.writeHead(303, { location: r.to });
|
if (r instanceof Redirect) {
|
||||||
return res.end();
|
res.writeHead(303, { location: r.to });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
const held = await api.held().then((h) => h.length).catch(() => 0);
|
||||||
|
return page(res, 200, r, url, held);
|
||||||
}
|
}
|
||||||
if (method !== "GET") return text(res, 405, "Method not allowed");
|
if (method !== "GET") return text(res, 405, "Method not allowed");
|
||||||
|
|
||||||
@@ -392,15 +464,32 @@ function safeBack(referer: string | undefined): URL | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readBody(req: IncomingMessage): Promise<string> {
|
async function readBody(req: IncomingMessage, max: number): Promise<Buffer> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
for await (const c of req) {
|
for await (const c of req) {
|
||||||
size += (c as Buffer).length;
|
size += (c as Buffer).length;
|
||||||
if (size > 200_000) throw new ApiError(413, "The form is too large", null);
|
if (size > max) throw new ApiError(413, "The form is too large", null);
|
||||||
chunks.push(c as Buffer);
|
chunks.push(c as Buffer);
|
||||||
}
|
}
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A posted form: plain fields, and any chosen files when it was sent as multipart. */
|
||||||
|
async function readForm(req: IncomingMessage): Promise<{ form: URLSearchParams; files: Upload[] }> {
|
||||||
|
const type = req.headers["content-type"] ?? "";
|
||||||
|
if (!type.startsWith("multipart/form-data")) {
|
||||||
|
return { form: new URLSearchParams((await readBody(req, 200_000)).toString("utf8")), files: [] };
|
||||||
|
}
|
||||||
|
const data = await new Request("http://admin/", { method: "POST", headers: { "content-type": type }, body: new Uint8Array(await readBody(req, MAX_UPLOAD)) }).formData();
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
const files: Upload[] = [];
|
||||||
|
for (const [field, v] of data.entries()) {
|
||||||
|
if (typeof v === "string") form.append(field, v);
|
||||||
|
// An empty file input still posts a nameless, empty part.
|
||||||
|
else if (v.size > 0 && v.name) files.push({ field, name: v.name, bytes: new Uint8Array(await v.arrayBuffer()) });
|
||||||
|
}
|
||||||
|
return { form, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(res: ServerResponse, status: number, body: string) {
|
function text(res: ServerResponse, status: number, body: string) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { draftFromForm, postFields } from "../src/posts.ts";
|
||||||
|
|
||||||
|
const form = (pairs: [string, string][]) => new URLSearchParams(pairs);
|
||||||
|
|
||||||
|
describe("post form", () => {
|
||||||
|
const d = draftFromForm(
|
||||||
|
form([
|
||||||
|
["title", " Pouring galaxy dice "],
|
||||||
|
["date", "2026-10-12"],
|
||||||
|
["topic", "Making"],
|
||||||
|
["postType", "article"],
|
||||||
|
["summary", "How the set came together."],
|
||||||
|
["cover", "[[galaxy.jpg]]"],
|
||||||
|
["links", "Resin"],
|
||||||
|
["links", "[[Galaxy dice]]"],
|
||||||
|
["links", ""],
|
||||||
|
["role", "Ignored for articles"],
|
||||||
|
["body", "Line one\r\nLine two"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reads the form into a draft", () => {
|
||||||
|
expect(d).toMatchObject({ title: "Pouring galaxy dice", cover: "galaxy.jpg", links: ["Resin", "Galaxy dice"], body: "Line one\nLine two" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes note fields, with links and the cover as wikilinks", () => {
|
||||||
|
expect(postFields(d, true, false)).toEqual({
|
||||||
|
title: "Pouring galaxy dice",
|
||||||
|
date: "2026-10-12",
|
||||||
|
topic: "Making",
|
||||||
|
postType: "article",
|
||||||
|
summary: "How the set came together.",
|
||||||
|
publish: true,
|
||||||
|
cover: "[[galaxy.jpg]]",
|
||||||
|
links: ["[[Resin]]", "[[Galaxy dice]]"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears emptied fields on an update", () => {
|
||||||
|
const f = postFields({ ...d, cover: "", links: [] }, false, true);
|
||||||
|
expect(f).toMatchObject({ cover: null, links: null, pinned: null, role: null, publish: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,9 +15,12 @@ import type { Change, ContentService } from "./service.ts";
|
|||||||
import type { Author, StoredNote } from "./store.ts";
|
import type { Author, StoredNote } from "./store.ts";
|
||||||
import { handleSubmissions } from "./submissions.ts";
|
import { handleSubmissions } from "./submissions.ts";
|
||||||
import { handleSuggestions } from "./suggestions.ts";
|
import { handleSuggestions } from "./suggestions.ts";
|
||||||
|
import { uploadTypes } from "./vault.ts";
|
||||||
import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts";
|
import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts";
|
||||||
|
|
||||||
const MAX_BODY = 1_000_000;
|
const MAX_BODY = 1_000_000;
|
||||||
|
/** The largest image the admin page can add. */
|
||||||
|
const MAX_IMAGE = 15_000_000;
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
constructor(readonly status: number, message: string) {
|
constructor(readonly status: number, message: string) {
|
||||||
@@ -73,6 +76,15 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
|||||||
|
|
||||||
const visible = (n: StoredNote | null, role: Role) => (n && !n.removed && (n.publish || can.readDrafts(role)) ? n : null);
|
const visible = (n: StoredNote | null, role: Role) => (n && !n.removed && (n.publish || can.readDrafts(role)) ? n : null);
|
||||||
|
|
||||||
|
/** Every attachment with the notes that link or embed it. */
|
||||||
|
function attachmentList() {
|
||||||
|
const notes = store.list({ limit: 100_000 });
|
||||||
|
return service.vault.attachmentFiles().map((a) => ({
|
||||||
|
...a,
|
||||||
|
usedBy: notes.filter((n) => n.text.includes(a.name)).map((n) => ({ path: n.path, title: n.title, type: n.type })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function route(req: IncomingMessage, res: ServerResponse) {
|
async function route(req: IncomingMessage, res: ServerResponse) {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
@@ -196,6 +208,11 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
|||||||
return send(res, 200, { ...heldView(h, { text: true }), current: current && !current.removed ? current.text : null });
|
return send(res, 200, { ...heldView(h, { text: true }), current: current && !current.removed ? current.text : null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attachments") {
|
||||||
|
need(can.review(role));
|
||||||
|
return send(res, 200, attachmentList());
|
||||||
|
}
|
||||||
|
|
||||||
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
let name = "";
|
let name = "";
|
||||||
@@ -250,6 +267,34 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
|||||||
return send(res, 200, { ok: true });
|
return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attachments") {
|
||||||
|
need(can.review(role));
|
||||||
|
const folder = q("folder") ?? "";
|
||||||
|
const name = q("name") ?? "";
|
||||||
|
const bytes = await readBytes(req, MAX_IMAGE);
|
||||||
|
if (!bytes.length) throw new HttpError(400, "The image is empty");
|
||||||
|
const ext = extname(name).toLowerCase();
|
||||||
|
if (!uploadTypes[ext] || !looksLike(ext, bytes)) throw new HttpError(400, `${name || "That file"} is not a PNG, JPEG, WebP, GIF or AVIF image`);
|
||||||
|
try {
|
||||||
|
return send(res, 200, { name: service.vault.addAttachment(folder, name, bytes) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(400, (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attachments/delete") {
|
||||||
|
need(can.review(role));
|
||||||
|
const body = z.object({ name: z.string().min(1) }).safeParse(await readJson(req));
|
||||||
|
if (!body.success) throw new HttpError(400, z.prettifyError(body.error));
|
||||||
|
const one = attachmentList().find((a) => a.name === body.data.name);
|
||||||
|
if (!one) throw new HttpError(404, "No such attachment");
|
||||||
|
if (one.usedBy.length) {
|
||||||
|
throw new HttpError(409, `${one.name} is still used by ${one.usedBy.map((u) => u.title).join(", ")}`);
|
||||||
|
}
|
||||||
|
service.vault.removeAttachment(one.name);
|
||||||
|
return send(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (path === "/api/scan") {
|
if (path === "/api/scan") {
|
||||||
need(can.review(role));
|
need(can.review(role));
|
||||||
return send(res, 200, service.scan());
|
return send(res, 200, service.scan());
|
||||||
@@ -296,6 +341,36 @@ async function readJson(req: IncomingMessage): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readBytes(req: IncomingMessage, max: number): Promise<Buffer> {
|
||||||
|
let size = 0;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += (chunk as Buffer).length;
|
||||||
|
if (size > max) throw new HttpError(413, `Images can be up to ${Math.round(max / 1_000_000)} MB`);
|
||||||
|
chunks.push(chunk as Buffer);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the bytes start the way the extension says, so only images land in the vault. */
|
||||||
|
function looksLike(ext: string, b: Buffer): boolean {
|
||||||
|
const at = (i: number, s: string) => b.subarray(i, i + s.length).toString("latin1") === s;
|
||||||
|
switch (ext) {
|
||||||
|
case ".png":
|
||||||
|
return at(0, "\x89PNG");
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
|
||||||
|
case ".gif":
|
||||||
|
return at(0, "GIF8");
|
||||||
|
case ".webp":
|
||||||
|
return at(0, "RIFF") && at(8, "WEBP");
|
||||||
|
case ".avif":
|
||||||
|
return at(4, "ftyp");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function send(res: ServerResponse, status: number, body: unknown) {
|
function send(res: ServerResponse, status: number, body: unknown) {
|
||||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* The Site folder on disk. Only the content service reads or writes it.
|
* The Site folder on disk. Only the content service reads or writes it.
|
||||||
*/
|
*/
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { basename, dirname, join, relative, sep } from "node:path";
|
import { basename, dirname, join, relative, sep } from "node:path";
|
||||||
|
|
||||||
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||||
@@ -22,6 +22,42 @@ export function isNotePath(rel: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Image types the admin page may add to `_attachments`. SVG stays out: it can carry script. */
|
||||||
|
export const uploadTypes: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".avif": "image/avif",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A safe attachment file name: lowercase words joined by hyphens, plus the extension. */
|
||||||
|
export function attachmentName(original: string): string | null {
|
||||||
|
const m = /^(.*?)(\.[A-Za-z0-9]+)?$/.exec(basename(original.replace(/\\/g, "/")));
|
||||||
|
const ext = (m?.[2] ?? "").toLowerCase();
|
||||||
|
if (!uploadTypes[ext]) return null;
|
||||||
|
const stem = (m?.[1] ?? "")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
return `${stem || "image"}${ext === ".jpeg" ? ".jpg" : ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folder under `_attachments`: one plain name like Posts or Pages. */
|
||||||
|
const folderOk = (f: string) => /^[A-Za-z0-9][A-Za-z0-9 _-]{0,40}$/.test(f);
|
||||||
|
|
||||||
|
export interface AttachmentFile {
|
||||||
|
name: string;
|
||||||
|
/** The folder under `_attachments`, or "" for its top level. */
|
||||||
|
folder: string;
|
||||||
|
size: number;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class Vault {
|
export class Vault {
|
||||||
constructor(readonly dir: string) {}
|
constructor(readonly dir: string) {}
|
||||||
|
|
||||||
@@ -77,6 +113,56 @@ export class Vault {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every file in `_attachments` with its folder, size and time. */
|
||||||
|
attachmentFiles(): AttachmentFile[] {
|
||||||
|
const root = join(this.dir, "_attachments");
|
||||||
|
const out: AttachmentFile[] = [];
|
||||||
|
const walk = (d: string) => {
|
||||||
|
if (!existsSync(d)) return;
|
||||||
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||||
|
if (e.name.startsWith(".")) continue;
|
||||||
|
const full = join(d, e.name);
|
||||||
|
if (e.isDirectory()) walk(full);
|
||||||
|
else if (e.isFile()) {
|
||||||
|
const st = statSync(full);
|
||||||
|
const folder = relative(root, d).split(sep).join("/");
|
||||||
|
out.push({ name: e.name, folder, size: st.size, modified: st.mtime.toISOString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
return out.sort((a, b) => b.modified.localeCompare(a.modified) || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an image to `_attachments/<folder>`. Names are made safe, and a name
|
||||||
|
* already used anywhere in `_attachments` gets -2, -3 and so on, because
|
||||||
|
* Obsidian links attachments by file name alone.
|
||||||
|
*/
|
||||||
|
addAttachment(folder: string, original: string, bytes: Uint8Array): string {
|
||||||
|
if (!folderOk(folder)) throw new Error("Use a plain folder name, like Posts");
|
||||||
|
const safe = attachmentName(original);
|
||||||
|
if (!safe) throw new Error(`Only ${Object.keys(uploadTypes).join(", ")} images can be added`);
|
||||||
|
const taken = new Set(this.listAttachments().map((n) => n.toLowerCase()));
|
||||||
|
const dot = safe.lastIndexOf(".");
|
||||||
|
let name = safe;
|
||||||
|
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${safe.slice(0, dot)}-${i}${safe.slice(dot)}`;
|
||||||
|
const full = join(this.dir, "_attachments", folder, name);
|
||||||
|
mkdirSync(dirname(full), { recursive: true });
|
||||||
|
const tmp = join(dirname(full), `.${name}.${process.pid}.tmp`);
|
||||||
|
writeFileSync(tmp, bytes);
|
||||||
|
renameSync(tmp, full);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete an attachment by file name. False when there is none. */
|
||||||
|
removeAttachment(name: string): boolean {
|
||||||
|
const file = name.includes("/") || name.includes("\\") || name.includes("..") ? null : this.attachmentPath(name);
|
||||||
|
if (!file) return false;
|
||||||
|
unlinkSync(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Absolute path of an attachment by file name, for serving images. */
|
/** Absolute path of an attachment by file name, for serving images. */
|
||||||
attachmentPath(name: string): string | null {
|
attachmentPath(name: string): string | null {
|
||||||
const walk = (d: string): string | null => {
|
const walk = (d: string): string | null => {
|
||||||
|
|||||||
@@ -77,6 +77,40 @@ describe("sync reports", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("attachments", () => {
|
||||||
|
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)]);
|
||||||
|
const upload = (name: string, bytes: Buffer, token = T.admin) =>
|
||||||
|
fetch(`${base}/api/attachments?folder=Posts&name=${encodeURIComponent(name)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/octet-stream" },
|
||||||
|
body: new Uint8Array(bytes),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an image under a safe, unused name", async () => {
|
||||||
|
const a = (await (await upload("My Photo!.PNG", png)).json()) as { name: string };
|
||||||
|
const b = (await (await upload("my photo.png", png)).json()) as { name: string };
|
||||||
|
expect(a.name).toBe("my-photo.png");
|
||||||
|
expect(b.name).toBe("my-photo-2.png");
|
||||||
|
expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns away other files and other roles", async () => {
|
||||||
|
expect((await upload("x.svg", Buffer.from("<svg/>"))).status).toBe(400);
|
||||||
|
expect((await upload("x.png", Buffer.from("not a png"))).status).toBe(400);
|
||||||
|
expect((await upload("x.png", png, T.agent)).status).toBe(403);
|
||||||
|
expect((await get("/api/attachments", T.reader)).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists images with the notes that use them, and deletes only unused ones", async () => {
|
||||||
|
await post_("/api/changes", T.admin, { op: "create", data: post("With a photo"), body: "![[my-photo.png]]" });
|
||||||
|
const list = (await (await get("/api/attachments", T.admin)).json()) as { name: string; folder: string; usedBy: { title: string }[] }[];
|
||||||
|
expect(list.find((a) => a.name === "my-photo.png")).toMatchObject({ folder: "Posts", usedBy: [{ title: "With a photo" }] });
|
||||||
|
expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo.png" })).status).toBe(409);
|
||||||
|
expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo-2.png" })).status).toBe(200);
|
||||||
|
expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("MCP server", () => {
|
describe("MCP server", () => {
|
||||||
it("lets an agent search, post a status and draft a post, but not publish it", async () => {
|
it("lets an agent search, post a status and draft a post, but not publish it", async () => {
|
||||||
const client = new Client({ name: "test", version: "1.0.0" });
|
const client = new Client({ name: "test", version: "1.0.0" });
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "Greg Kilmer",
|
||||||
|
"short_name": "Greg Kilmer",
|
||||||
|
"description": "Greg Kilmer: developer, maker, storyteller.",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#0E1522",
|
||||||
|
"background_color": "#0E1522",
|
||||||
|
"display": "standalone",
|
||||||
|
"start_url": "/"
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
/**
|
/**
|
||||||
* Greg's portrait in a gold frame. No portrait file exists yet, so the
|
* Greg's portrait in a gold frame, from the Page text note's `portrait`.
|
||||||
* star-chart art stands in; pass `src` once there is one.
|
* Without one, or if it fails to load, the star-chart art stands in.
|
||||||
*/
|
*/
|
||||||
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
|
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
|
||||||
|
import Star from "@gk/ui/components/Star.astro";
|
||||||
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||||
import type { SealName } from "@gk/ui/components/seals.ts";
|
import type { SealName } from "@gk/ui/components/seals.ts";
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
|
|||||||
<figure class:list={["portrait", variant, className]}>
|
<figure class:list={["portrait", variant, className]}>
|
||||||
<StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" />
|
<StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" />
|
||||||
{variant === "panel" && <GiltCorners inset={4} size={30} />}
|
{variant === "panel" && <GiltCorners inset={4} size={30} />}
|
||||||
|
{variant === "hero" && <Star size={34} class="spark tl" />}
|
||||||
|
{variant === "hero" && <Star size={24} class="spark br" />}
|
||||||
{!src && <figcaption class="gk-visually-hidden">Portrait coming soon</figcaption>}
|
{!src && <figcaption class="gk-visually-hidden">Portrait coming soon</figcaption>}
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
@@ -38,6 +41,20 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
|
|||||||
.hero :global(.art) {
|
.hero :global(.art) {
|
||||||
border-radius: var(--gk-radius);
|
border-radius: var(--gk-radius);
|
||||||
}
|
}
|
||||||
|
.hero :global(.photo) {
|
||||||
|
object-position: 50% 20%;
|
||||||
|
}
|
||||||
|
.hero :global(.spark) {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.hero :global(.tl) {
|
||||||
|
left: -17px;
|
||||||
|
top: -17px;
|
||||||
|
}
|
||||||
|
.hero :global(.br) {
|
||||||
|
right: -12px;
|
||||||
|
bottom: -12px;
|
||||||
|
}
|
||||||
.panel {
|
.panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -50,4 +67,7 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
.panel :global(.photo) {
|
||||||
|
object-position: 50% 25%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
/**
|
/**
|
||||||
* The favorites constellation: round covers strung on a dotted gold line,
|
* 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 Star from "@gk/ui/components/Star.astro";
|
||||||
|
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||||
import TickScale from "@gk/ui/components/TickScale.astro";
|
import TickScale from "@gk/ui/components/TickScale.astro";
|
||||||
import { mediaSrc } from "../../lib/site.ts";
|
import { mediaSrc } from "../../lib/site.ts";
|
||||||
import type { Item } from "../../lib/library.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) => (
|
items.map((item, i) => (
|
||||||
<li style={`--lift:${lift[i % lift.length]}px`}>
|
<li style={`--lift:${lift[i % lift.length]}px`}>
|
||||||
<a href={`#${item.slug}`}>
|
<a href={`#${item.slug}`}>
|
||||||
<img src={mediaSrc(item.data.cover)} alt="" width="80" height="80" decoding="async" />
|
<StarChartArt class="orb" ratio="1 / 1" seal="zodiac" src={mediaSrc(item.data.cover)} />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -104,18 +106,32 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
|
|||||||
color: var(--gk-text);
|
color: var(--gk-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
img {
|
a :global(.orb) {
|
||||||
|
flex: none;
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
|
||||||
background: var(--gk-midnight);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px var(--gk-gold),
|
0 0 0 2px var(--gk-gold),
|
||||||
0 0 22px rgba(201, 162, 74, 0.45);
|
0 0 22px rgba(201, 162, 74, 0.45);
|
||||||
transition: box-shadow 0.2s;
|
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:
|
box-shadow:
|
||||||
0 0 0 2px var(--gk-gold-bright),
|
0 0 0 2px var(--gk-gold-bright),
|
||||||
0 0 30px rgba(201, 162, 74, 0.7);
|
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 {
|
li {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
img {
|
a :global(.orb) {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
|||||||
import StatusPill from "@gk/ui/components/StatusPill.astro";
|
import StatusPill from "@gk/ui/components/StatusPill.astro";
|
||||||
import StatusChip from "../StatusChip.astro";
|
import StatusChip from "../StatusChip.astro";
|
||||||
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
|
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 {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
@@ -24,6 +24,8 @@ const plays = d.howItPlays ?? [];
|
|||||||
const status = site.statusFor(game);
|
const status = site.statusFor(game);
|
||||||
// Players and play time sync from BoardGameGeek; nothing shows until they have.
|
// Players and play time sync from BoardGameGeek; nothing shows until they have.
|
||||||
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
|
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
|
||||||
|
const seats = playersOf(game);
|
||||||
|
const hasPost = site.backlinks(game, { type: "post" }).length > 0;
|
||||||
---
|
---
|
||||||
<li
|
<li
|
||||||
id={game.slug}
|
id={game.slug}
|
||||||
@@ -34,6 +36,9 @@ const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
|
|||||||
data-f={d.favorite ? "1" : undefined}
|
data-f={d.favorite ? "1" : undefined}
|
||||||
data-lp={d.lastPlayed}
|
data-lp={d.lastPlayed}
|
||||||
data-c={d.created}
|
data-c={d.created}
|
||||||
|
data-pl={seats?.join("-")}
|
||||||
|
data-t={minutesOf(game)}
|
||||||
|
data-b={hasPost ? "1" : undefined}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
>
|
>
|
||||||
<Card as="div" class="game-card">
|
<Card as="div" class="game-card">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
---
|
---
|
||||||
/** The closing band on a world page: a line, a note and one or two buttons. */
|
/**
|
||||||
|
* The closing band on a world page: a full-width strip with a line, a note and
|
||||||
|
* one or two buttons, like the one on Work.
|
||||||
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
@@ -7,25 +10,32 @@ interface Props {
|
|||||||
const { title, note } = Astro.props;
|
const { title, note } = Astro.props;
|
||||||
---
|
---
|
||||||
<aside class="cta" aria-label={title}>
|
<aside class="cta" aria-label={title}>
|
||||||
<div class="words">
|
<div class="inner">
|
||||||
<p class="t">{title}</p>
|
<div class="words">
|
||||||
{note && <p class="n">{note}</p>}
|
<p class="t">{title}</p>
|
||||||
|
{note && <p class="n">{note}</p>}
|
||||||
|
</div>
|
||||||
|
<div class="btns"><slot /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btns"><slot /></div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cta {
|
.cta {
|
||||||
|
margin-inline: calc(50% - 50vw);
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
padding: 32px 36px;
|
background: var(--gk-midnight);
|
||||||
|
border-top: 1px solid var(--gk-lines);
|
||||||
|
border-bottom: 1px solid var(--gk-lines);
|
||||||
|
}
|
||||||
|
.inner {
|
||||||
|
max-width: var(--gk-page-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 36px var(--gk-gutter);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
background: var(--gk-midnight);
|
|
||||||
border: 1px solid var(--gk-lines);
|
|
||||||
border-radius: var(--gk-radius);
|
|
||||||
}
|
}
|
||||||
.words {
|
.words {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -50,8 +60,8 @@ const { title, note } = Astro.props;
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.cta {
|
.inner {
|
||||||
padding: 24px 20px;
|
padding-block: 28px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const sorts = [
|
|||||||
{ key: "played", label: "Last played" },
|
{ key: "played", label: "Last played" },
|
||||||
{ key: "az", label: "A to Z" },
|
{ key: "az", label: "A to Z" },
|
||||||
{ key: "added", label: "Recently added" },
|
{ key: "added", label: "Recently added" },
|
||||||
|
{ key: "time", label: "Play time" },
|
||||||
] as const;
|
] as const;
|
||||||
export type SortKey = (typeof sorts)[number]["key"];
|
export type SortKey = (typeof sorts)[number]["key"];
|
||||||
|
|
||||||
@@ -138,6 +139,8 @@ export function sortGames(games: Game[], sort: SortKey): Game[] {
|
|||||||
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
|
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
|
||||||
az,
|
az,
|
||||||
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
|
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
|
||||||
|
// Shortest first; games BoardGameGeek has not timed go last.
|
||||||
|
time: (a, b) => (minutesOf(a) ?? Infinity) - (minutesOf(b) ?? Infinity) || az(a, b),
|
||||||
};
|
};
|
||||||
return [...games].sort(cmp[sort]);
|
return [...games].sort(cmp[sort]);
|
||||||
}
|
}
|
||||||
@@ -148,9 +151,29 @@ export interface ShelfFilter {
|
|||||||
owned: boolean;
|
owned: boolean;
|
||||||
want: boolean;
|
want: boolean;
|
||||||
favorites: boolean;
|
favorites: boolean;
|
||||||
|
blog: boolean;
|
||||||
|
players: PlayersKey | "";
|
||||||
|
time: TimeKey | "";
|
||||||
sort: SortKey;
|
sort: SortKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Players pills: who a game has to seat. */
|
||||||
|
export const playerOptions = [
|
||||||
|
{ key: "1", label: "1", lo: 1, hi: 1 },
|
||||||
|
{ key: "2", label: "2", lo: 2, hi: 2 },
|
||||||
|
{ key: "3-4", label: "3–4", lo: 3, hi: 4 },
|
||||||
|
{ key: "5+", label: "5+", lo: 5, hi: Infinity },
|
||||||
|
] as const;
|
||||||
|
export type PlayersKey = (typeof playerOptions)[number]["key"];
|
||||||
|
|
||||||
|
/** The Play time pills, in minutes. */
|
||||||
|
export const timeOptions = [
|
||||||
|
{ key: "short", label: "Under 30 min", lo: 0, hi: 29 },
|
||||||
|
{ key: "mid", label: "30–60", lo: 30, hi: 60 },
|
||||||
|
{ key: "long", label: "60+", lo: 61, hi: Infinity },
|
||||||
|
] as const;
|
||||||
|
export type TimeKey = (typeof timeOptions)[number]["key"];
|
||||||
|
|
||||||
export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
||||||
const submitted = params.has("f");
|
const submitted = params.has("f");
|
||||||
return {
|
return {
|
||||||
@@ -159,19 +182,27 @@ export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
|||||||
owned: submitted ? params.get("owned") === "1" : true,
|
owned: submitted ? params.get("owned") === "1" : true,
|
||||||
want: params.get("want") === "1",
|
want: params.get("want") === "1",
|
||||||
favorites: params.get("fav") === "1",
|
favorites: params.get("fav") === "1",
|
||||||
|
blog: params.get("blog") === "1",
|
||||||
|
players: playerOptions.find((o) => o.key === params.get("players"))?.key ?? "",
|
||||||
|
time: timeOptions.find((o) => o.key === params.get("time"))?.key ?? "",
|
||||||
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
|
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owned and Want to play add games to the list (either one); with neither
|
* Owned and Want to play add games to the list (either one); with neither
|
||||||
* ticked, every game shows. Favorites only and How it plays narrow it.
|
* ticked, every game shows. Favorites only, Has a blog post, How it plays,
|
||||||
|
* Players and Play time narrow it. A game BoardGameGeek has not synced yet
|
||||||
|
* drops out while Players or Play time is set.
|
||||||
*/
|
*/
|
||||||
export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
|
export function gameShows(g: Game, f: ShelfFilter, text: string, hasPost = false): boolean {
|
||||||
const owned = !!g.data.owned;
|
const owned = !!g.data.owned;
|
||||||
const want = !!g.data.wantToPlay;
|
const want = !!g.data.wantToPlay;
|
||||||
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
|
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
|
||||||
if (f.favorites && !g.data.favorite) return false;
|
if (f.favorites && !g.data.favorite) return false;
|
||||||
|
if (f.blog && !hasPost) return false;
|
||||||
|
if (f.players && !seats(g, playerOptions.find((o) => o.key === f.players)!)) return false;
|
||||||
|
if (f.time && !lasts(g, timeOptions.find((o) => o.key === f.time)!)) return false;
|
||||||
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
|
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
|
||||||
return !f.q || text.includes(f.q);
|
return !f.q || text.includes(f.q);
|
||||||
}
|
}
|
||||||
@@ -185,6 +216,30 @@ interface GameSynced {
|
|||||||
maxPlayTime?: number;
|
maxPlayTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The player range BoardGameGeek gives, when it has synced. */
|
||||||
|
export function playersOf(g: Game): [number, number] | undefined {
|
||||||
|
const d = g.data as GameSynced;
|
||||||
|
const lo = d.minPlayers || d.maxPlayers;
|
||||||
|
const hi = d.maxPlayers || d.minPlayers;
|
||||||
|
return lo && hi ? [lo, hi] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A game's usual length in minutes: the listed play time, else the longest. */
|
||||||
|
export function minutesOf(g: Game): number | undefined {
|
||||||
|
const d = g.data as GameSynced;
|
||||||
|
return d.playTime || d.maxPlayTime || d.minPlayTime || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a game plays at some count in the option's range. */
|
||||||
|
const seats = (g: Game, o: { lo: number; hi: number }) => {
|
||||||
|
const r = playersOf(g);
|
||||||
|
return !!r && r[0] <= o.hi && r[1] >= o.lo;
|
||||||
|
};
|
||||||
|
const lasts = (g: Game, o: { lo: number; hi: number }) => {
|
||||||
|
const m = minutesOf(g);
|
||||||
|
return m !== undefined && m >= o.lo && m <= o.hi;
|
||||||
|
};
|
||||||
|
|
||||||
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
|
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
|
||||||
|
|
||||||
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
|
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
|
||||||
|
|||||||
@@ -43,7 +43,21 @@ interface Col {
|
|||||||
w: number;
|
w: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(r: ResumeData): Promise<Buffer> {
|
interface Body {
|
||||||
|
pdf: Buffer;
|
||||||
|
pages: number;
|
||||||
|
/** Where the last line ended on the last page, in points from the top. */
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays the resume out with every gap between blocks multiplied by `spread`
|
||||||
|
* (and line spacing by a little less), so a short resume can be opened up to
|
||||||
|
* fill its one page instead of leaving the bottom empty.
|
||||||
|
*/
|
||||||
|
function renderBody(r: ResumeData, spread = 1): Promise<Body> {
|
||||||
|
const gapDown = (n: number) => doc.moveDown(n * spread);
|
||||||
|
const lg = (n: number) => n + (spread - 1) * 1.2;
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: PAGE.size,
|
size: PAGE.size,
|
||||||
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right },
|
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right },
|
||||||
@@ -57,8 +71,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
|
const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||||
const done = new Promise<Buffer>((resolve, reject) => {
|
let pages = 1;
|
||||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
let end = 0;
|
||||||
|
const done = new Promise<Body>((resolve, reject) => {
|
||||||
|
doc.on("end", () => resolve({ pdf: Buffer.concat(chunks), pages, end }));
|
||||||
doc.on("error", reject);
|
doc.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +89,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
|
|
||||||
const section = (title: string, c: Col = full) => {
|
const section = (title: string, c: Col = full) => {
|
||||||
room(50);
|
room(50);
|
||||||
doc.moveDown(0.4);
|
gapDown(0.4);
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
|
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
|
||||||
const ruleY = doc.y + 1;
|
const ruleY = doc.y + 1;
|
||||||
@@ -85,16 +101,16 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
const indent = 11;
|
const indent = 11;
|
||||||
const w = c.w - indent;
|
const w = c.w - indent;
|
||||||
doc.font("body").fontSize(TEXT);
|
doc.font("body").fontSize(TEXT);
|
||||||
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1 }) + 3);
|
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: lg(1) }) + 3);
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
diamond(c.x + 3.5, y + 5.5, 2.1);
|
diamond(c.x + 3.5, y + 5.5, 2.1);
|
||||||
if (lead) {
|
if (lead) {
|
||||||
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1 });
|
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: lg(1) });
|
||||||
doc.font("body").text(` ${text}`, { lineGap: 1 });
|
doc.font("body").text(` ${text}`, { lineGap: lg(1) });
|
||||||
} else {
|
} else {
|
||||||
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1 });
|
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: lg(1) });
|
||||||
}
|
}
|
||||||
doc.moveDown(0.12);
|
gapDown(0.12);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A title on the left with its dates set flush right on the same line. */
|
/** A title on the left with its dates set flush right on the same line. */
|
||||||
@@ -122,20 +138,20 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
|
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
|
||||||
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
|
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
|
||||||
{
|
{
|
||||||
const y = doc.moveDown(0.15).y;
|
const y = gapDown(0.15).y;
|
||||||
doc.font("body").fontSize(8).fillColor(MUTED);
|
doc.font("body").fontSize(8).fillColor(MUTED);
|
||||||
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
|
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
|
||||||
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
|
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
|
||||||
doc.text(links, full.x, y, { width: full.w - 120 });
|
doc.text(links, full.x, y, { width: full.w - 120 });
|
||||||
}
|
}
|
||||||
doc.moveDown(0.6);
|
gapDown(0.6);
|
||||||
|
|
||||||
if (r.summary) {
|
if (r.summary) {
|
||||||
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.4 });
|
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: lg(1.4) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.stats.length) {
|
if (r.stats.length) {
|
||||||
doc.moveDown(0.6);
|
gapDown(0.6);
|
||||||
const cols = Math.min(r.stats.length, 4);
|
const cols = Math.min(r.stats.length, 4);
|
||||||
const colW = full.w / cols;
|
const colW = full.w / cols;
|
||||||
const top = doc.y;
|
const top = doc.y;
|
||||||
@@ -156,14 +172,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
section("Experience");
|
section("Experience");
|
||||||
r.roles.forEach((role, i) => {
|
r.roles.forEach((role, i) => {
|
||||||
const d = role.data;
|
const d = role.data;
|
||||||
if (i > 0) doc.moveDown(0.5);
|
if (i > 0) gapDown(0.5);
|
||||||
titled(d.role, period(role), 10.5);
|
titled(d.role, period(role), 10.5);
|
||||||
const at = [d.company, d.location].filter(Boolean).join(" · ");
|
const at = [d.company, d.location].filter(Boolean).join(" · ");
|
||||||
doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
|
doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
|
||||||
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
|
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
|
||||||
// The focus line repeats what the bullets say, so paper skips it when there are bullets.
|
// The focus line repeats what the bullets say, so paper skips it when there are bullets.
|
||||||
if (d.focus && !d.bullets?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
|
if (d.focus && !d.bullets?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
|
||||||
doc.moveDown(0.25);
|
gapDown(0.25);
|
||||||
for (const b of d.bullets ?? []) bullet(b);
|
for (const b of d.bullets ?? []) bullet(b);
|
||||||
doc.x = full.x;
|
doc.x = full.x;
|
||||||
});
|
});
|
||||||
@@ -173,7 +189,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
section("Earlier");
|
section("Earlier");
|
||||||
r.earlier.forEach((role, i) => {
|
r.earlier.forEach((role, i) => {
|
||||||
const d = role.data;
|
const d = role.data;
|
||||||
if (i > 0) doc.moveDown(0.3);
|
if (i > 0) gapDown(0.3);
|
||||||
titled(d.role, period(role), 9.5);
|
titled(d.role, period(role), 9.5);
|
||||||
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w });
|
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w });
|
||||||
if (d.focus) doc.font("body").fontSize(8).fillColor(MUTED).text(d.focus, { width: full.w });
|
if (d.focus) doc.font("body").fontSize(8).fillColor(MUTED).text(d.focus, { width: full.w });
|
||||||
@@ -193,14 +209,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
doc.font("strong");
|
doc.font("strong");
|
||||||
const lh = doc.heightOfString(g.label, { width: labelW });
|
const lh = doc.heightOfString(g.label, { width: labelW });
|
||||||
doc.font("body");
|
doc.font("body");
|
||||||
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.6 })) + 2;
|
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: lg(0.6) })) + 2;
|
||||||
}, 24);
|
}, 24);
|
||||||
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
|
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
|
||||||
doc.font("body");
|
doc.font("body");
|
||||||
const rightH =
|
const rightH =
|
||||||
(entries ? 24 + entries * 26 : 0) +
|
(entries ? 24 + entries * 26 : 0) +
|
||||||
(r.projects.length ? 24 : 0) +
|
(r.projects.length ? 24 : 0) +
|
||||||
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1 }) + 3, 0);
|
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: lg(1) }) + 3, 0);
|
||||||
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
|
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
|
||||||
const start = doc.y;
|
const start = doc.y;
|
||||||
let leftEnd = start;
|
let leftEnd = start;
|
||||||
@@ -211,7 +227,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
|
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
|
||||||
const afterLabel = doc.y;
|
const afterLabel = doc.y;
|
||||||
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.6 });
|
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: lg(0.6) });
|
||||||
doc.y = Math.max(doc.y, afterLabel) + 2;
|
doc.y = Math.max(doc.y, afterLabel) + 2;
|
||||||
}
|
}
|
||||||
leftEnd = doc.y;
|
leftEnd = doc.y;
|
||||||
@@ -224,12 +240,12 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
for (const dg of edu?.degrees ?? []) {
|
for (const dg of edu?.degrees ?? []) {
|
||||||
titled(dg.credential, dg.period, 9.5, rightCol);
|
titled(dg.credential, dg.period, 9.5, rightCol);
|
||||||
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
|
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
|
||||||
doc.moveDown(0.3);
|
gapDown(0.3);
|
||||||
}
|
}
|
||||||
for (const c of edu?.certifications ?? []) {
|
for (const c of edu?.certifications ?? []) {
|
||||||
titled(c.name, c.year ?? "", 9.5, rightCol);
|
titled(c.name, c.year ?? "", 9.5, rightCol);
|
||||||
if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w });
|
if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w });
|
||||||
doc.moveDown(0.3);
|
gapDown(0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (r.projects.length) {
|
if (r.projects.length) {
|
||||||
@@ -239,8 +255,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
doc.x = full.x;
|
doc.x = full.x;
|
||||||
doc.y = Math.max(doc.y, leftEnd);
|
doc.y = Math.max(doc.y, leftEnd);
|
||||||
|
|
||||||
|
end = doc.y;
|
||||||
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
|
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
|
||||||
const range = doc.bufferedPageRange();
|
const range = doc.bufferedPageRange();
|
||||||
|
pages = range.count;
|
||||||
if (range.count > 1) {
|
if (range.count > 1) {
|
||||||
for (let i = range.start; i < range.start + range.count; i++) {
|
for (let i = range.start; i < range.start + range.count; i++) {
|
||||||
doc.switchToPage(i);
|
doc.switchToPage(i);
|
||||||
@@ -257,9 +275,29 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
|||||||
return done;
|
return done;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The page's lowest writing line, in points from the top. */
|
||||||
|
const LIMIT = 792 - PAGE.bottom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-page resume that ends well above the footer is laid out again with
|
||||||
|
* wider gaps, up to twice as wide, so the page reads as full; the widest
|
||||||
|
* spread that still fits on one page wins. Longer resumes are left as they are.
|
||||||
|
*/
|
||||||
|
export async function fitBody(r: ResumeData): Promise<Body> {
|
||||||
|
const first = await renderBody(r);
|
||||||
|
if (first.pages > 1 || LIMIT - first.end < 24) return first;
|
||||||
|
let best = first;
|
||||||
|
for (let spread = 1.1; spread <= 2.001; spread += 0.1) {
|
||||||
|
const next = await renderBody(r, spread);
|
||||||
|
if (next.pages > 1 || next.end > LIMIT - 4) break;
|
||||||
|
best = next;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/** The resume laid over the letterhead: each page draws the letterhead, then the resume on top, both as vectors. */
|
/** The resume laid over the letterhead: each page draws the letterhead, then the resume on top, both as vectors. */
|
||||||
export async function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
export async function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||||
const body = await PdfLib.load(await renderBody(r));
|
const body = await PdfLib.load((await fitBody(r)).pdf);
|
||||||
const out = await PdfLib.create();
|
const out = await PdfLib.create();
|
||||||
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
|
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
|
||||||
out.setAuthor(r.name);
|
out.setAuthor(r.name);
|
||||||
|
|||||||
@@ -164,3 +164,12 @@ const CATEGORY_ICONS: Record<Category, string> = { Tabletop: "dice", Paper: "boo
|
|||||||
export function lineIcon(line: Line): string {
|
export function lineIcon(line: Line): string {
|
||||||
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
|
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Store's own header: the Store, each line sold here (by its menu name),
|
||||||
|
* and Order request. Lines sold elsewhere stay on the Store page only.
|
||||||
|
*/
|
||||||
|
export function storeNav(st: StoreState, current: string): { links: { label: string; href: string }[]; current: string } {
|
||||||
|
const lines = st.lines.filter((l) => channelOf(l) === "here").map((l) => ({ label: l.data.menuName ?? l.title, href: `/store/${l.slug}` }));
|
||||||
|
return { links: [{ label: "Store", href: "/store" }, ...lines, { label: "Order request", href: "/store/order" }], current };
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
|
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
|
||||||
import Icon from "@gk/ui/components/Icon.astro";
|
import Icon from "@gk/ui/components/Icon.astro";
|
||||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||||
import Seal from "@gk/ui/components/Seal.astro";
|
|
||||||
import SectionHeading from "@gk/ui/components/SectionHeading.astro";
|
import SectionHeading from "@gk/ui/components/SectionHeading.astro";
|
||||||
import Star from "@gk/ui/components/Star.astro";
|
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 TickScale from "@gk/ui/components/TickScale.astro";
|
||||||
import PageHero from "../components/PageHero.astro";
|
import PageHero from "../components/PageHero.astro";
|
||||||
import DoorLink from "../components/home/DoorLink.astro";
|
import DoorLink from "../components/home/DoorLink.astro";
|
||||||
@@ -17,7 +17,8 @@ import Portrait from "../components/home/Portrait.astro";
|
|||||||
import { splitIntro, viewStatus } from "../lib/home.ts";
|
import { splitIntro, viewStatus } from "../lib/home.ts";
|
||||||
import { brandIcon } from "../lib/site.ts";
|
import { brandIcon } from "../lib/site.ts";
|
||||||
import { site } from "../lib/page.ts";
|
import { site } from "../lib/page.ts";
|
||||||
import { formatDate } from "../lib/site.ts";
|
import { formatDate, mediaSrc } from "../lib/site.ts";
|
||||||
|
import scorpius from "@gk/ui/assets/motifs/scorpius.svg?url";
|
||||||
|
|
||||||
const s = await site();
|
const s = await site();
|
||||||
const text = s.pageText("/about");
|
const text = s.pageText("/about");
|
||||||
@@ -25,6 +26,7 @@ const settings = s.settings();
|
|||||||
const { intro, sections } = splitIntro(text?.body ?? "");
|
const { intro, sections } = splitIntro(text?.body ?? "");
|
||||||
const introHtml = s.render(intro);
|
const introHtml = s.render(intro);
|
||||||
const facts = text?.data.quickFacts ?? [];
|
const facts = text?.data.quickFacts ?? [];
|
||||||
|
const photos = text?.data.photos ?? [];
|
||||||
const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st));
|
const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st));
|
||||||
const nowUpdated = now.map((v) => v.date).sort().pop();
|
const nowUpdated = now.map((v) => v.date).sort().pop();
|
||||||
const socials = settings?.socials ?? [];
|
const socials = settings?.socials ?? [];
|
||||||
@@ -43,7 +45,7 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
|||||||
{introHtml ? <blockquote class="iam" set:html={introHtml} /> : <blockquote class="iam"><p>[PLACEHOLDER]</p></blockquote>}
|
{introHtml ? <blockquote class="iam" set:html={introHtml} /> : <blockquote class="iam"><p>[PLACEHOLDER]</p></blockquote>}
|
||||||
</div>
|
</div>
|
||||||
<div class="intro-portrait">
|
<div class="intro-portrait">
|
||||||
<Portrait variant="panel" seal="astrolabe" />
|
<Portrait variant="panel" seal="astrolabe" src={mediaSrc(text?.data.portrait)} alt="Greg, a candid portrait" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -60,6 +62,18 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
|||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
photos.length > 0 && (
|
||||||
|
<div class="photos">
|
||||||
|
{photos.map((ph) => (
|
||||||
|
<figure class="photo">
|
||||||
|
<StarChartArt seal="astrolabe" ratio="4 / 3" src={mediaSrc(ph.file)} alt={ph.alt} class="photo-art" />
|
||||||
|
{ph.caption && <figcaption>{ph.caption}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside aria-label="Quick facts" class="facts">
|
<aside aria-label="Quick facts" class="facts">
|
||||||
@@ -127,7 +141,7 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
|||||||
Two wings of the same house
|
Two wings of the same house
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
<div class="wing-wrap">
|
<div class="wing-wrap">
|
||||||
<div class="wing-seal" aria-hidden="true"><Seal name="astrolabe" size={360} /></div>
|
<div class="wing-stars" aria-hidden="true"><img src={scorpius} alt="" width="1100" height="400" /></div>
|
||||||
<div class="wing-row">
|
<div class="wing-row">
|
||||||
<div class="wing featured">
|
<div class="wing featured">
|
||||||
<GiltCorners inset={6} size={30} />
|
<GiltCorners inset={6} size={30} />
|
||||||
@@ -226,6 +240,27 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.photo {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.photo :global(.photo-art) {
|
||||||
|
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||||
|
}
|
||||||
|
.photo figcaption {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gk-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.facts {
|
.facts {
|
||||||
flex: 1 1 280px;
|
flex: 1 1 280px;
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
@@ -361,7 +396,8 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
|||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
.wing-seal {
|
/* Scorpius, from catalog positions, showing through the 70% opaque wings. */
|
||||||
|
.wing-stars {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -70px 0;
|
inset: -70px 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
@@ -369,9 +405,14 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.12;
|
opacity: 0.55;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.wing-stars img {
|
||||||
|
width: 1100px;
|
||||||
|
max-width: none;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
.wing-row {
|
.wing-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import NowCard from "../components/home/NowCard.astro";
|
|||||||
import Portrait from "../components/home/Portrait.astro";
|
import Portrait from "../components/home/Portrait.astro";
|
||||||
import { currentRole, viewStatus, worldPhoto } from "../lib/home.ts";
|
import { currentRole, viewStatus, worldPhoto } from "../lib/home.ts";
|
||||||
import { site } from "../lib/page.ts";
|
import { site } from "../lib/page.ts";
|
||||||
|
import { mediaSrc } from "../lib/site.ts";
|
||||||
|
|
||||||
const s = await site();
|
const s = await site();
|
||||||
const settings = s.settings();
|
const settings = s.settings();
|
||||||
@@ -50,7 +51,7 @@ const listFormat = new Intl.ListFormat("en", { style: "long", type: "conjunction
|
|||||||
{intro ? <div class="lede" set:html={intro} /> : <p class="lede">[PLACEHOLDER]</p>}
|
{intro ? <div class="lede" set:html={intro} /> : <p class="lede">[PLACEHOLDER]</p>}
|
||||||
</div>
|
</div>
|
||||||
<div class="portrait-wrap">
|
<div class="portrait-wrap">
|
||||||
<Portrait variant="hero" seal="transmutation-star-paths" />
|
<Portrait variant="hero" seal="transmutation-star-paths" src={mediaSrc(text?.data.portrait)} alt={name} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* constellation, then the list grouped Now / Up next / Finished, filtered by
|
* constellation, then the list grouped Now / Up next / Finished, filtered by
|
||||||
* medium, state, favorites and blog posts.
|
* medium, state, favorites and blog posts.
|
||||||
*/
|
*/
|
||||||
import Divider from "@gk/ui/components/Divider.astro";
|
|
||||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||||
import SearchField from "@gk/ui/components/SearchField.astro";
|
import SearchField from "@gk/ui/components/SearchField.astro";
|
||||||
import Constellation from "../../components/library/Constellation.astro";
|
import Constellation from "../../components/library/Constellation.astro";
|
||||||
@@ -45,10 +44,10 @@ const typeHref = (key?: string) => {
|
|||||||
const FIRST = 12;
|
const FIRST = 12;
|
||||||
---
|
---
|
||||||
<PageShell title="Library" family="library" current="library" description="What Greg is reading, watching and playing now, what's next, and what stuck with him.">
|
<PageShell title="Library" family="library" current="library" description="What Greg is reading, watching and playing now, what's next, and what stuck with him.">
|
||||||
<Fragment slot="hero"><LibraryHero current="library" lede={lede} /></Fragment>
|
<Fragment slot="hero">
|
||||||
|
<LibraryHero current="library" lede={lede} />
|
||||||
<Constellation items={constellation(items)} />
|
<Constellation items={constellation(items)} />
|
||||||
<div class="divide"><Divider seed="library-list" /></div>
|
</Fragment>
|
||||||
|
|
||||||
<FilterLayout action="/library" formId="lib-filters">
|
<FilterLayout action="/library" formId="lib-filters">
|
||||||
<Fragment slot="rail">
|
<Fragment slot="rail">
|
||||||
@@ -88,9 +87,6 @@ const FIRST = 12;
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.divide {
|
|
||||||
padding: 24px 0 4px;
|
|
||||||
}
|
|
||||||
.types {
|
.types {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* counts, play times and box art sync from BoardGameGeek (a cover set in the
|
* counts, play times and box art sync from BoardGameGeek (a cover set in the
|
||||||
* note wins); no filter for them yet.
|
* note wins); no filter for them yet.
|
||||||
*/
|
*/
|
||||||
import Divider from "@gk/ui/components/Divider.astro";
|
|
||||||
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
|
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
|
||||||
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
|
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
|
||||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||||
@@ -18,7 +17,7 @@ import FilterLayout from "../../components/library/FilterLayout.astro";
|
|||||||
import GameCard from "../../components/library/GameCard.astro";
|
import GameCard from "../../components/library/GameCard.astro";
|
||||||
import LibraryHero from "../../components/library/LibraryHero.astro";
|
import LibraryHero from "../../components/library/LibraryHero.astro";
|
||||||
import RailGroup from "../../components/library/RailGroup.astro";
|
import RailGroup from "../../components/library/RailGroup.astro";
|
||||||
import { gameShows, onTheTable, playStyles, searchText, shelfFilter, sortGames, sorts, type Game } from "../../lib/library.ts";
|
import { gameShows, minutesOf, onTheTable, playerOptions, playersOf, playStyles, searchText, shelfFilter, sortGames, sorts, timeOptions, type Game } from "../../lib/library.ts";
|
||||||
import { site } from "../../lib/page.ts";
|
import { site } from "../../lib/page.ts";
|
||||||
import { formatDate, mediaSrc } from "../../lib/site.ts";
|
import { formatDate, mediaSrc } from "../../lib/site.ts";
|
||||||
|
|
||||||
@@ -31,7 +30,16 @@ const owned = all.filter((g) => g.data.owned).length;
|
|||||||
const lede = s.pageText("/library/shelf")?.data.lede ?? "My board game collection. Filter by how it plays, sort by what hit the table last.";
|
const lede = s.pageText("/library/shelf")?.data.lede ?? "My board game collection. Filter by how it plays, sort by what hit the table last.";
|
||||||
|
|
||||||
const text = (g: Game) => searchText([g.title, g.data.blurb, ...(g.data.howItPlays ?? [])]);
|
const text = (g: Game) => searchText([g.title, g.data.blurb, ...(g.data.howItPlays ?? [])]);
|
||||||
const visible = new Set(games.filter((g) => gameShows(g, f, text(g))).map((g) => g.slug));
|
const hasPost = (g: Game) => s.backlinks(g, { type: "post" }).length > 0;
|
||||||
|
const visible = new Set(games.filter((g) => gameShows(g, f, text(g), hasPost(g))).map((g) => g.slug));
|
||||||
|
// Players and play time come from BoardGameGeek; their pills wait for a sync.
|
||||||
|
const synced = { players: all.some((g) => playersOf(g)), time: all.some((g) => minutesOf(g) !== undefined) };
|
||||||
|
const anyPost = all.some(hasPost);
|
||||||
|
/** Games shown before "Show all", while no filter narrows the list. */
|
||||||
|
const FIRST = 12;
|
||||||
|
const narrowed = !!(f.q || f.plays.size || f.want || !f.owned || f.favorites || f.blog || f.players || f.time);
|
||||||
|
const folded = !narrowed && visible.size > FIRST;
|
||||||
|
const firstShown = new Set(games.filter((g) => visible.has(g.slug)).slice(0, FIRST).map((g) => g.slug));
|
||||||
|
|
||||||
// On the table: board-game statuses, then the most recent play not already listed.
|
// On the table: board-game statuses, then the most recent play not already listed.
|
||||||
const table: { label: string; game: Game; note?: string; live: boolean }[] = onTheTable(s).map(({ status, game }) => ({
|
const table: { label: string; game: Game; note?: string; live: boolean }[] = onTheTable(s).map(({ status, game }) => ({
|
||||||
@@ -50,43 +58,42 @@ const sortHref = (key: string) => {
|
|||||||
};
|
};
|
||||||
---
|
---
|
||||||
<PageShell title="The Shelf" family="shelf" current="library" description="Greg's board game collection: what is on the table, how each one plays, and what hit the table last.">
|
<PageShell title="The Shelf" family="shelf" current="library" description="Greg's board game collection: what is on the table, how each one plays, and what hit the table last.">
|
||||||
<Fragment slot="hero"><LibraryHero current="shelf" lede={lede} /></Fragment>
|
<Fragment slot="hero">
|
||||||
|
<LibraryHero current="shelf" lede={lede} />
|
||||||
<section aria-labelledby="sh-table" class="top">
|
<section aria-labelledby="sh-table" class="top">
|
||||||
<div class="count gk-featured">
|
<div class="count gk-featured">
|
||||||
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
|
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
|
||||||
<GiltCorners />
|
<GiltCorners />
|
||||||
<p class="count-text"><span class="num">{owned}</span><span>{owned === 1 ? "game" : "games"} on the shelf</span></p>
|
<p class="count-text"><span class="num">{owned}</span><span>{owned === 1 ? "game" : "games"} on the shelf</span></p>
|
||||||
</div>
|
|
||||||
<div class="table">
|
|
||||||
<div class="kick">
|
|
||||||
<h2 id="sh-table"><Star size={14} />On the table</h2>
|
|
||||||
<TickScale width={150} height={10} count={30} />
|
|
||||||
</div>
|
</div>
|
||||||
{
|
<div class="table">
|
||||||
table.length > 0 ? (
|
<div class="kick">
|
||||||
<ul class="table-list">
|
<h2 id="sh-table"><Star size={14} />On the table</h2>
|
||||||
{table.map((t) => (
|
<TickScale width={150} height={10} count={30} />
|
||||||
<li>
|
</div>
|
||||||
<a href={`#${t.game.slug}`} class:list={["gk-card", "table-card", { live: t.live }]}>
|
{
|
||||||
<StarChartArt class="table-art" ratio="1 / 1" seal="tria-prima" src={mediaSrc(t.game.data.cover)} />
|
table.length > 0 ? (
|
||||||
<span class="table-body">
|
<ul class="table-list">
|
||||||
<span class="table-label">{t.label}</span>
|
{table.map((t) => (
|
||||||
<span class="table-title">{t.game.title}</span>
|
<li>
|
||||||
{t.note && <span class="table-note">{t.note}</span>}
|
<a href={`#${t.game.slug}`} class:list={["gk-card", "table-card", { live: t.live }]}>
|
||||||
</span>
|
<StarChartArt class="table-art" ratio="1 / 1" seal="tria-prima" src={mediaSrc(t.game.data.cover)} />
|
||||||
</a>
|
<span class="table-body">
|
||||||
</li>
|
<span class="table-label">{t.label}</span>
|
||||||
))}
|
<span class="table-title">{t.game.title}</span>
|
||||||
</ul>
|
{t.note && <span class="table-note">{t.note}</span>}
|
||||||
) : (
|
</span>
|
||||||
<p class="quiet">Nothing on the table right now.</p>
|
</a>
|
||||||
)
|
</li>
|
||||||
}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</section>
|
) : (
|
||||||
|
<p class="quiet">Nothing on the table right now.</p>
|
||||||
<div class="divide"><Divider seed="shelf-list" /></div>
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Fragment>
|
||||||
|
|
||||||
<FilterLayout action="/library/shelf" formId="shelf-filters">
|
<FilterLayout action="/library/shelf" formId="shelf-filters">
|
||||||
<Fragment slot="rail">
|
<Fragment slot="rail">
|
||||||
@@ -99,11 +106,29 @@ const sortHref = (key: string) => {
|
|||||||
</RailGroup>
|
</RailGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
synced.players && (
|
||||||
|
<RailGroup legend="Players" pills>
|
||||||
|
<label class="gk-filter pill"><input type="radio" name="players" value="" checked={!f.players} class="sr" /><span class="gk-fs star"><Star size={9} /></span>Any</label>
|
||||||
|
{playerOptions.map((o) => <label class="gk-filter pill"><input type="radio" name="players" value={o.key} checked={f.players === o.key} class="sr" /><span class="gk-fs star"><Star size={9} /></span>{o.label}</label>)}
|
||||||
|
</RailGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
synced.time && (
|
||||||
|
<RailGroup legend="Play time" pills>
|
||||||
|
<label class="gk-filter pill"><input type="radio" name="time" value="" checked={!f.time} class="sr" /><span class="gk-fs star"><Star size={9} /></span>Any</label>
|
||||||
|
{timeOptions.map((o) => <label class="gk-filter pill"><input type="radio" name="time" value={o.key} checked={f.time === o.key} class="sr" /><span class="gk-fs star"><Star size={9} /></span>{o.label}</label>)}
|
||||||
|
</RailGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
<RailGroup legend="Show">
|
<RailGroup legend="Show">
|
||||||
<label class="check"><input type="checkbox" name="owned" value="1" checked={f.owned} />Owned</label>
|
<label class="check"><input type="checkbox" name="owned" value="1" checked={f.owned} />Owned</label>
|
||||||
<label class="check"><input type="checkbox" name="want" value="1" checked={f.want} />Want to play</label>
|
<label class="check"><input type="checkbox" name="want" value="1" checked={f.want} />Want to play</label>
|
||||||
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
|
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
|
||||||
|
{anyPost && <label class="check"><input type="checkbox" name="blog" value="1" checked={f.blog} />Has a blog post</label>}
|
||||||
</RailGroup>
|
</RailGroup>
|
||||||
|
{(synced.players || synced.time) && <p class="rail-note">Player counts and play times come from BoardGameGeek.</p>}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
<section aria-label="Games" class="games">
|
<section aria-label="Games" class="games">
|
||||||
@@ -115,8 +140,9 @@ const sortHref = (key: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="grid" data-grid>
|
<ul class="grid" data-grid>
|
||||||
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug)} />)}
|
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug) || (folded && !firstShown.has(g.slug))} />)}
|
||||||
</ul>
|
</ul>
|
||||||
|
<button type="button" class="gk-text more" data-more hidden={!folded}>Show all {visible.size}</button>
|
||||||
<p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
|
<p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
|
||||||
</section>
|
</section>
|
||||||
</FilterLayout>
|
</FilterLayout>
|
||||||
@@ -252,8 +278,36 @@ const sortHref = (key: string) => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--gk-muted);
|
color: var(--gk-muted);
|
||||||
}
|
}
|
||||||
.divide {
|
.rail-note {
|
||||||
padding: 24px 0 4px;
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--gk-muted);
|
||||||
|
}
|
||||||
|
.sr {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pill:has(:checked) {
|
||||||
|
border-color: var(--gk-gold);
|
||||||
|
background: var(--gk-gold-tint);
|
||||||
|
color: var(--gk-text);
|
||||||
|
}
|
||||||
|
.pill:has(:checked) .star {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.pill:has(:focus-visible) {
|
||||||
|
outline: 2px solid var(--gk-gold);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.more {
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
.games {
|
.games {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -322,7 +376,13 @@ const sortHref = (key: string) => {
|
|||||||
played: desc("lp"),
|
played: desc("lp"),
|
||||||
az: (a, b) => title(a).localeCompare(title(b)),
|
az: (a, b) => title(a).localeCompare(title(b)),
|
||||||
added: desc("c"),
|
added: desc("c"),
|
||||||
|
time: (a, b) => Number(a.dataset.t || Infinity) - Number(b.dataset.t || Infinity) || title(a).localeCompare(title(b)),
|
||||||
};
|
};
|
||||||
|
const FIRST = 12;
|
||||||
|
const more = document.querySelector<HTMLButtonElement>("[data-more]");
|
||||||
|
const players: Record<string, [number, number]> = { "1": [1, 1], "2": [2, 2], "3-4": [3, 4], "5+": [5, Infinity] };
|
||||||
|
const times: Record<string, [number, number]> = { short: [0, 29], mid: [30, 60], long: [61, Infinity] };
|
||||||
|
let expanded = false;
|
||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
@@ -331,16 +391,29 @@ const sortHref = (key: string) => {
|
|||||||
const owned = data.has("owned");
|
const owned = data.has("owned");
|
||||||
const want = data.has("want");
|
const want = data.has("want");
|
||||||
const fav = data.has("fav");
|
const fav = data.has("fav");
|
||||||
|
const blog = data.has("blog");
|
||||||
|
const seat = players[String(data.get("players") ?? "")];
|
||||||
|
const time = times[String(data.get("time") ?? "")];
|
||||||
|
const narrowed = !!(q || plays.length || want || !owned || fav || blog || seat || time);
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const c of cards) {
|
for (const c of [...grid.children] as HTMLElement[]) {
|
||||||
const d = c.dataset;
|
const d = c.dataset;
|
||||||
|
const [lo, hi] = (d.pl ?? "").split("-").map(Number);
|
||||||
|
const t = d.t ? Number(d.t) : NaN;
|
||||||
const ok =
|
const ok =
|
||||||
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
|
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
|
||||||
(!fav || !!d.f) &&
|
(!fav || !!d.f) &&
|
||||||
|
(!blog || !!d.b) &&
|
||||||
|
(!seat || (!!d.pl && lo! <= seat[1] && hi! >= seat[0])) &&
|
||||||
|
(!time || (t >= time[0] && t <= time[1])) &&
|
||||||
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
|
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
|
||||||
(!q || text.get(c)!.includes(q));
|
(!q || text.get(c)!.includes(q));
|
||||||
c.hidden = !ok;
|
|
||||||
if (ok) n++;
|
if (ok) n++;
|
||||||
|
c.hidden = !ok || (!narrowed && !expanded && n > FIRST);
|
||||||
|
}
|
||||||
|
if (more) {
|
||||||
|
more.hidden = narrowed || expanded || n <= FIRST;
|
||||||
|
more.textContent = `Show all ${n}`;
|
||||||
}
|
}
|
||||||
if (count) count.textContent = String(n);
|
if (count) count.textContent = String(n);
|
||||||
if (noun) noun.textContent = n === 1 ? "game" : "games";
|
if (noun) noun.textContent = n === 1 ? "game" : "games";
|
||||||
@@ -350,8 +423,24 @@ const sortHref = (key: string) => {
|
|||||||
const sort = (key: string) => {
|
const sort = (key: string) => {
|
||||||
sortInput.value = key;
|
sortInput.value = key;
|
||||||
grid.append(...[...cards].sort(orders[key] ?? orders.played));
|
grid.append(...[...cards].sort(orders[key] ?? orders.played));
|
||||||
|
apply();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
more?.addEventListener("click", () => {
|
||||||
|
expanded = true;
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
// Opening an anchor to a folded-away card unfolds the grid first.
|
||||||
|
const reveal = () => {
|
||||||
|
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||||
|
if (target && target.hidden && cards.includes(target)) {
|
||||||
|
expanded = true;
|
||||||
|
apply();
|
||||||
|
target.scrollIntoView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addEventListener("hashchange", reveal);
|
||||||
|
reveal();
|
||||||
form.addEventListener("input", apply);
|
form.addEventListener("input", apply);
|
||||||
form.addEventListener("submit", (e) => {
|
form.addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
|
|||||||
<TickScale width={120} count={24} />
|
<TickScale width={120} count={24} />
|
||||||
</span>
|
</span>
|
||||||
<Button href="/resume.pdf" download="Greg-Kilmer-Resume.pdf">
|
<Button href="/resume.pdf" download="Greg-Kilmer-Resume.pdf">
|
||||||
<Icon name="arrow-right" size={18} />Download PDF
|
<Icon name="download" size={18} />Download PDF
|
||||||
</Button>
|
</Button>
|
||||||
{r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>}
|
{r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>}
|
||||||
<Button variant="secondary" href="/inquiries?type=job">Start an inquiry</Button>
|
<Button variant="secondary" href="/inquiries?type=job">Start an inquiry</Button>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import Swatches from "../../components/store/Swatches.astro";
|
|||||||
import Terms from "../../components/store/Terms.astro";
|
import Terms from "../../components/store/Terms.astro";
|
||||||
import { site } from "../../lib/page.ts";
|
import { site } from "../../lib/page.ts";
|
||||||
import { formatDate, mediaSrc } from "../../lib/site.ts";
|
import { formatDate, mediaSrc } from "../../lib/site.ts";
|
||||||
import { canOrder, channelOf, hasCapacity, optionGroups, priceSummary, shopLink, storeState } from "../../lib/store.ts";
|
import { canOrder, channelOf, hasCapacity, optionGroups, priceSummary, shopLink, storeNav, storeState } from "../../lib/store.ts";
|
||||||
|
|
||||||
const s = await site();
|
const s = await site();
|
||||||
const slug = Astro.params.slug ?? "";
|
const slug = Astro.params.slug ?? "";
|
||||||
@@ -40,7 +40,7 @@ const groups = optionGroups(line);
|
|||||||
const body = s.render(line.body);
|
const body = s.render(line.body);
|
||||||
const terms = [...(d.terms ?? []), ...st.terms];
|
const terms = [...(d.terms ?? []), ...st.terms];
|
||||||
---
|
---
|
||||||
<PageShell title={line.title} family="store" current="store" description={d.summary}>
|
<PageShell title={line.title} family="store" current="store" store={storeNav(st, `/store/${line.slug}`)} description={d.summary}>
|
||||||
<div class="crumb-row">
|
<div class="crumb-row">
|
||||||
<Breadcrumb here={d.category} />
|
<Breadcrumb here={d.category} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import Swatches from "../../components/store/Swatches.astro";
|
|||||||
import Terms from "../../components/store/Terms.astro";
|
import Terms from "../../components/store/Terms.astro";
|
||||||
import { site } from "../../lib/page.ts";
|
import { site } from "../../lib/page.ts";
|
||||||
import { formatDate } from "../../lib/site.ts";
|
import { formatDate } from "../../lib/site.ts";
|
||||||
import { channelOf, optionGroups, storeState } from "../../lib/store.ts";
|
import { channelOf, optionGroups, storeNav, storeState } from "../../lib/store.ts";
|
||||||
|
|
||||||
const s = await site();
|
const s = await site();
|
||||||
const st = storeState(s);
|
const st = storeState(s);
|
||||||
@@ -38,7 +38,7 @@ const colorStrips = selling.flatMap((l) =>
|
|||||||
.filter((x) => x.opts.length),
|
.filter((x) => x.opts.length),
|
||||||
);
|
);
|
||||||
---
|
---
|
||||||
<PageShell title="Store" family="store" current="store" description={st.settings?.capacityNote ?? "Things I make, in small batches."}>
|
<PageShell title="Store" family="store" current="store" store={storeNav(st, "/store")} description={st.settings?.capacityNote ?? "Things I make, in small batches."}>
|
||||||
<Fragment slot="hero">
|
<Fragment slot="hero">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<div class="hero-main">
|
<div class="hero-main">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import LineStatus from "../../components/store/LineStatus.astro";
|
|||||||
import OrderForm from "../../components/store/OrderForm.astro";
|
import OrderForm from "../../components/store/OrderForm.astro";
|
||||||
import { site } from "../../lib/page.ts";
|
import { site } from "../../lib/page.ts";
|
||||||
import { formatDate } from "../../lib/site.ts";
|
import { formatDate } from "../../lib/site.ts";
|
||||||
import { canOrder, channelOf, storeState } from "../../lib/store.ts";
|
import { canOrder, channelOf, storeNav, storeState } from "../../lib/store.ts";
|
||||||
import { errorMessages, parseDraft, parseErrorCode } from "../../lib/forms.ts";
|
import { errorMessages, parseDraft, parseErrorCode } from "../../lib/forms.ts";
|
||||||
import { turnstileSiteKey } from "../../lib/forms-server.ts";
|
import { turnstileSiteKey } from "../../lib/forms-server.ts";
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const shown = orderable.length ? orderable : selling;
|
|||||||
const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround).filter((t): t is string => !!t))];
|
const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround).filter((t): t is string => !!t))];
|
||||||
const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens ${formatDate(c.reopens)}.`);
|
const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens ${formatDate(c.reopens)}.`);
|
||||||
---
|
---
|
||||||
<PageShell title="Order request" family="store" current="store" description="Send an order request for something from the Store.">
|
<PageShell title="Order request" family="store" current="store" store={storeNav(st, "/store/order")} description="Send an order request for something from the Store.">
|
||||||
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
|
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
|
||||||
<section class="intro" aria-labelledby="order-h1">
|
<section class="intro" aria-labelledby="order-h1">
|
||||||
<Breadcrumb here="Order request" />
|
<Breadcrumb here="Order request" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import TickScale from "@gk/ui/components/TickScale.astro";
|
|||||||
import Breadcrumb from "../../../components/store/Breadcrumb.astro";
|
import Breadcrumb from "../../../components/store/Breadcrumb.astro";
|
||||||
import { parseOrderSent } from "../../../lib/forms.ts";
|
import { parseOrderSent } from "../../../lib/forms.ts";
|
||||||
import { site } from "../../../lib/page.ts";
|
import { site } from "../../../lib/page.ts";
|
||||||
import { storeState } from "../../../lib/store.ts";
|
import { storeNav, storeState } from "../../../lib/store.ts";
|
||||||
|
|
||||||
const sent = parseOrderSent(Astro.cookies.get("gk-order")?.value);
|
const sent = parseOrderSent(Astro.cookies.get("gk-order")?.value);
|
||||||
if (sent) Astro.cookies.delete("gk-order", { path: "/" });
|
if (sent) Astro.cookies.delete("gk-order", { path: "/" });
|
||||||
@@ -21,8 +21,9 @@ Astro.response.headers.set("Cache-Control", "private, no-store");
|
|||||||
|
|
||||||
const s = await site().catch(() => null);
|
const s = await site().catch(() => null);
|
||||||
const replyTime = s?.settings()?.replyTime ?? "a few days";
|
const replyTime = s?.settings()?.replyTime ?? "a few days";
|
||||||
const line = sent?.lineSlug && s ? storeState(s).lines.find((l) => l.slug === sent.lineSlug) : undefined;
|
const st = s ? storeState(s) : undefined;
|
||||||
const turnaround = line && s ? storeState(s).category(line.data.category).turnaround : undefined;
|
const line = sent?.lineSlug ? st?.lines.find((l) => l.slug === sent.lineSlug) : undefined;
|
||||||
|
const turnaround = line ? st?.category(line.data.category).turnaround : undefined;
|
||||||
const rows = [
|
const rows = [
|
||||||
...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
|
...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
|
||||||
...(sent?.rows ?? []),
|
...(sent?.rows ?? []),
|
||||||
@@ -35,7 +36,7 @@ const steps = [
|
|||||||
{ title: "I start once you say yes", text: turnaround ?? "Nothing is made until we agree." },
|
{ title: "I start once you say yes", text: turnaround ?? "Nothing is made until we agree." },
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
<PageShell title="Order request sent" family="store" current="store" description="Your order request is on its way.">
|
<PageShell title="Order request sent" family="store" current="store" store={st ? storeNav(st, "/store/order") : undefined} description="Your order request is on its way.">
|
||||||
<meta slot="head" name="robots" content="noindex" />
|
<meta slot="head" name="robots" content="noindex" />
|
||||||
<section class="intro" aria-labelledby="sent-h1">
|
<section class="intro" aria-labelledby="sent-h1">
|
||||||
<Breadcrumb here="Request sent" />
|
<Breadcrumb here="Request sent" />
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
|
|||||||
|
|
||||||
.cards {
|
.cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Button from "@gk/ui/components/Button.astro";
|
import Button from "@gk/ui/components/Button.astro";
|
||||||
import Chip from "@gk/ui/components/Chip.astro";
|
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 PageShell from "@gk/ui/components/PageShell.astro";
|
||||||
import Seal from "@gk/ui/components/Seal.astro";
|
import Seal from "@gk/ui/components/Seal.astro";
|
||||||
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||||
@@ -52,6 +53,16 @@ const nowItems: NowItem[] = s
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Kinds of make, from their tags (the sword rack tag is the Workshop's own):
|
||||||
|
// pills over the grid once there are two or more to choose between.
|
||||||
|
const kindsOf = (m: Note<"make">) => (m.data.tags ?? []).filter((t) => t.toLowerCase() !== "sword rack");
|
||||||
|
const kinds = [...new Set(makes.flatMap(kindsOf))].sort((a, b) => a.localeCompare(b));
|
||||||
|
const untagged = makes.some((m) => kindsOf(m).length === 0);
|
||||||
|
const kindPills =
|
||||||
|
kinds.length + (untagged ? 1 : 0) >= 2
|
||||||
|
? [{ label: "All", value: "", active: true }, ...kinds.map((k) => ({ label: k, value: k.toLowerCase() })), ...(untagged ? [{ label: "Other", value: "-" }] : [])]
|
||||||
|
: [];
|
||||||
|
|
||||||
const meta = [plural(makes.length, "make"), logs.length > 0 && plural(logs.length, "build log")].filter(Boolean).join(" · ");
|
const meta = [plural(makes.length, "make"), logs.length > 0 && plural(logs.length, "build log")].filter(Boolean).join(" · ");
|
||||||
const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).cover);
|
const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).cover);
|
||||||
---
|
---
|
||||||
@@ -98,6 +109,7 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
|
|||||||
}
|
}
|
||||||
|
|
||||||
<WorldSection id="cr-makes" title="Makes" intro={makes.length > 1 ? "Newest first." : undefined}>
|
<WorldSection id="cr-makes" title="Makes" intro={makes.length > 1 ? "Newest first." : undefined}>
|
||||||
|
{kindPills.length > 0 && <div slot="action" class="kinds" data-kinds hidden><FilterGroup label="Kind of make" items={kindPills} /></div>}
|
||||||
{
|
{
|
||||||
makes.length > 0 ? (
|
makes.length > 0 ? (
|
||||||
<ul class="makes">
|
<ul class="makes">
|
||||||
@@ -105,7 +117,7 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
|
|||||||
const photos = makePhotos(m);
|
const photos = makePhotos(m);
|
||||||
const log = s.backlinks(m, { type: "post" })[0];
|
const log = s.backlinks(m, { type: "post" })[0];
|
||||||
return (
|
return (
|
||||||
<li id={m.slug} class="make">
|
<li id={m.slug} class="make" data-k={kindsOf(m).map((k) => k.toLowerCase()).join("|") || "-"}>
|
||||||
<StarChartArt seal={artSeal} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
|
<StarChartArt seal={artSeal} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
|
||||||
{photos.length > 1 && (
|
{photos.length > 1 && (
|
||||||
<span class="m-more">
|
<span class="m-more">
|
||||||
@@ -170,6 +182,23 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
|
|||||||
</CtaBand>
|
</CtaBand>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The kind pills filter the makes grid; without a script every make shows.
|
||||||
|
const box = document.querySelector<HTMLElement>("[data-kinds]");
|
||||||
|
if (box) {
|
||||||
|
const pills = [...box.querySelectorAll<HTMLButtonElement>("button[data-value]")];
|
||||||
|
const makes = [...document.querySelectorAll<HTMLElement>(".makes > li[data-k]")];
|
||||||
|
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.k ?? "").split("|").includes(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.crumb {
|
.crumb {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fitBody } from "../src/lib/resume-pdf.ts";
|
||||||
|
import type { ResumeData } from "../src/lib/resume.ts";
|
||||||
|
|
||||||
|
const role = (n: number) =>
|
||||||
|
({
|
||||||
|
path: `Resume/Role ${n}.md`,
|
||||||
|
title: `Role ${n}`,
|
||||||
|
data: { type: "experience", title: `Role ${n}`, publish: true, company: "Example Co", role: `Role ${n}`, start: "2019-03", bullets: ["Built a thing that did something useful for people every day.", "Led a small team."] },
|
||||||
|
}) as unknown as ResumeData["roles"][number];
|
||||||
|
|
||||||
|
const base: ResumeData = {
|
||||||
|
name: "Greg Kilmer",
|
||||||
|
headline: "Team Lead",
|
||||||
|
summary: "A short summary.",
|
||||||
|
stats: [],
|
||||||
|
roles: [role(1)],
|
||||||
|
earlier: [],
|
||||||
|
projects: [],
|
||||||
|
socials: [],
|
||||||
|
updated: "2026-10-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resume PDF fit", () => {
|
||||||
|
it("opens a short resume up to fill its one page", async () => {
|
||||||
|
const medium = { ...base, roles: Array.from({ length: 6 }, (_, i) => role(i)) };
|
||||||
|
const fitted = await fitBody(medium);
|
||||||
|
expect(fitted.pages).toBe(1);
|
||||||
|
expect(fitted.end).toBeLessThanOrEqual(792 - 70);
|
||||||
|
// At normal spacing six short roles end well above the middle of the page.
|
||||||
|
expect(fitted.end).toBeGreaterThan(560);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a resume that runs past one page alone", async () => {
|
||||||
|
const long = await fitBody({ ...base, roles: Array.from({ length: 12 }, (_, i) => role(i)) });
|
||||||
|
expect(long.pages).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,8 +78,17 @@ export function build(legacyDir: string): BuildResult {
|
|||||||
add({ type: "site-settings", publish: true, ...seed.site });
|
add({ type: "site-settings", publish: true, ...seed.site });
|
||||||
add({ type: "status-kinds", title: "Status kinds", publish: true, kinds: seed.statusKinds });
|
add({ type: "status-kinds", title: "Status kinds", publish: true, kinds: seed.statusKinds });
|
||||||
for (const p of seed.pages) {
|
for (const p of seed.pages) {
|
||||||
const { body, source: _source, ...rest } = p;
|
const { body, source: _source, portrait, photos, ...rest } = p as typeof p & { portrait?: string; photos?: { file: string; alt: string; caption?: string }[] };
|
||||||
add({ type: "page", created: today, ...rest }, body);
|
add(
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
created: today,
|
||||||
|
...rest,
|
||||||
|
portrait: portrait ? attach(portrait, "Pages") : undefined,
|
||||||
|
photos: photos?.map((ph) => ({ ...ph, file: attach(ph.file, "Pages") })),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ Library: reading
|
// ------------------------------------------------------------ Library: reading
|
||||||
@@ -340,6 +349,7 @@ export function build(legacyDir: string): BuildResult {
|
|||||||
mode: "made to order",
|
mode: "made to order",
|
||||||
channel: "here",
|
channel: "here",
|
||||||
summary: p.summary,
|
summary: p.summary,
|
||||||
|
menuName: ({ cards: "Proxy cards", deckBoxes: "Deck boxes" } as Record<string, string>)[p.slotKey],
|
||||||
icon: p.icon,
|
icon: p.icon,
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
// Greg (2026-10-11): orders stay closed until he opens them, so no slot shows as open.
|
// Greg (2026-10-11): orders stay closed until he opens them, so no slot shows as open.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const pages = [
|
|||||||
title: "Landing",
|
title: "Landing",
|
||||||
page: "/",
|
page: "/",
|
||||||
publish: true,
|
publish: true,
|
||||||
|
portrait: "/images/hero-portrait.jpg",
|
||||||
body: "I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico Inc., run D&D campaigns that span years, and fill my shelves with Brandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.",
|
body: "I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico Inc., run D&D campaigns that span years, and fill my shelves with Brandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.",
|
||||||
source: "legacy/src/pages/index.astro",
|
source: "legacy/src/pages/index.astro",
|
||||||
},
|
},
|
||||||
@@ -56,6 +57,11 @@ export const pages = [
|
|||||||
title: "About me",
|
title: "About me",
|
||||||
page: "/about",
|
page: "/about",
|
||||||
publish: true,
|
publish: true,
|
||||||
|
portrait: "/images/about-portrait.jpg",
|
||||||
|
photos: [
|
||||||
|
{ file: "/images/kashi_at_work.jpg", alt: "Kashi, a mini dachshund, at work with his dog dad", caption: "Kashi, on the job" },
|
||||||
|
{ file: "/images/tea-gaiwan.jpg", alt: "A tea gaiwan set on a mushroom towel", caption: "Loose-leaf tea, the other fuel" },
|
||||||
|
],
|
||||||
quickFacts: [
|
quickFacts: [
|
||||||
{ icon: "map-marker", text: "Philadelphia, PA" },
|
{ icon: "map-marker", text: "Philadelphia, PA" },
|
||||||
{ icon: "briefcase", text: "BTP, Cloud, and Mobile App Lead at Implico Inc." },
|
{ icon: "briefcase", text: "BTP, Cloud, and Mobile App Lead at Implico Inc." },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ publish: false # Only notes set to true appear on the site.
|
|||||||
created: "{{date:YYYY-MM-DD}}" # optional
|
created: "{{date:YYYY-MM-DD}}" # optional
|
||||||
page: # The page address this text belongs to, like /about.
|
page: # The page address this text belongs to, like /about.
|
||||||
lede: # optional. The one line under the page title.
|
lede: # optional. The one line under the page title.
|
||||||
|
portrait: # optional. Greg's photo on the landing and About pages.
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- The page's introduction. -->
|
<!-- The page's introduction. -->
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mode: # one of: made to order, in stock
|
|||||||
channel: # one of: here, Etsy, Patiun.com, coming soon
|
channel: # one of: here, Etsy, Patiun.com, coming soon
|
||||||
channelUrl: # optional. For Etsy or Patiun.com lines.
|
channelUrl: # optional. For Etsy or Patiun.com lines.
|
||||||
summary:
|
summary:
|
||||||
|
menuName: # optional. A short name for the Store menu, like Proxy cards.
|
||||||
icon: # optional
|
icon: # optional
|
||||||
order: # optional
|
order: # optional
|
||||||
slots: # optional. Made to order lines only.
|
slots: # optional. Made to order lines only.
|
||||||
|
|||||||