Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10a7d0a878 | |||
| 5442a8d888 | |||
| 9390a7cbc0 | |||
| a9fb5b3e85 | |||
| 151bed230a | |||
| 3f6167455d | |||
| 449ffcd62d | |||
| bd97720be1 | |||
| 5c3ab14ba3 | |||
| a945bb08cc | |||
| 10f206c756 |
@@ -39,7 +39,9 @@ cp deploy/compose.yaml "$STACK/compose.yaml"
|
||||
set_setting GK_TAG "$TAG"
|
||||
[ -n "$CURRENT" ] && set_setting GK_PREVIOUS_TAG "$CURRENT"
|
||||
|
||||
if ! compose up -d --remove-orphans --wait --wait-timeout 120; then
|
||||
# No --remove-orphans: another stack may share the project name, and its
|
||||
# containers must never be touched by this deploy.
|
||||
if ! compose up -d --wait --wait-timeout 120; then
|
||||
echo "!! $TAG did not come up healthy"
|
||||
compose ps
|
||||
compose logs --tail 50 || true
|
||||
|
||||
@@ -19,6 +19,8 @@ the vault so Obsidian shows them too. Its changes are recorded with the author
|
||||
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
|
||||
| Notes, Drafts | Browse and search every note, or only the unpublished ones. |
|
||||
| A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
||||
| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. |
|
||||
| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. |
|
||||
| Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. |
|
||||
| Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. |
|
||||
| Problems | Whole-folder problems, like links to notes that do not exist. |
|
||||
@@ -34,8 +36,13 @@ the vault so Obsidian shows them too. Its changes are recorded with the author
|
||||
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
|
||||
browser act on the admin page.
|
||||
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
|
||||
are not http, https, mailto or relative. The page sends a content security
|
||||
policy that allows no scripts at all; it works entirely with plain forms.
|
||||
are not http, https, mailto or relative. The content security policy allows
|
||||
only the page's own `/admin.js` (Copy buttons and a confirm before deleting
|
||||
an image), never inline script, and every page still works with plain forms
|
||||
when that script does not run.
|
||||
- **Only images go into the vault.** Uploads are checked by extension and by
|
||||
their first bytes, SVG is refused, and an image is deleted only when no note
|
||||
uses it.
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -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/note/rollback` | admin | `{ path, rev }` |
|
||||
| `POST /api/scan` | editor, admin | Compare the whole folder now. |
|
||||
| `GET /api/attachments` | editor, admin | Every file in `_attachments`, newest first, with its folder, size and the notes whose text names it (`usedBy`). |
|
||||
| `POST /api/attachments?folder=&name=` | editor, admin | The raw bytes of a PNG, JPEG, WebP, GIF or AVIF image (up to 15 MB; SVG is refused because it can carry script). It is saved in `_attachments/<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/<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. |
|
||||
@@ -148,7 +151,7 @@ being down never takes a page down. Code: [`services/content/src/sync/`](../serv
|
||||
| `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` |
|
||||
| `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) |
|
||||
| `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) |
|
||||
| `bambuddy` | hour | Filament spools in stock (**unverified API**, below) | `filamentStock` on product lines with a Filament option |
|
||||
| `bambuddy` | hour | Filament spools in stock | `filamentStock` on product lines with a Filament option |
|
||||
| `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option |
|
||||
|
||||
The covers job looks every 15 minutes, so a new Library item gets a cover
|
||||
@@ -227,15 +230,15 @@ stock when the source does not have it; in-stock colors the note does not
|
||||
list are added after the note's own. With nothing synced, options show
|
||||
exactly as the note says (a missing `inStock` counts as in stock).
|
||||
|
||||
- **Bambuddy: unverified.** Its API is not documented anywhere this project
|
||||
has, and no request has been made to one. The endpoint is
|
||||
`BAMBUDDY_SPOOLS_PATH` (default `/api/v1/inventory/spools`, a guess), and all
|
||||
knowledge of the response is in `mapBambuddySpools` in
|
||||
`sync/jobs/bambuddy.ts`: it accepts a list (or one under `items`, `spools`,
|
||||
`data`, `results`) and reads color name, hex, material and grams left under
|
||||
common spellings. Check it against Greg's Bambuddy and fix that one function
|
||||
before relying on it. A color is in stock when some spool of it has at least
|
||||
`BAMBUDDY_MIN_GRAMS` (default 50) left, or does not say.
|
||||
- **Bambuddy** reads `GET /api/v1/inventory/spools` (`BAMBUDDY_SPOOLS_PATH`)
|
||||
with an API key made in Bambuddy's Settings, sent in the `X-API-Key` header.
|
||||
Field names were checked against Greg's Bambuddy (2026-10-12) and are all in
|
||||
`mapBambuddySpools` in `sync/jobs/bambuddy.ts`. Grams left is `label_weight`
|
||||
minus `weight_used`; archived spools are skipped. A spool with no
|
||||
`color_name` (RFID spools) is named by its nearest plain color (Black,
|
||||
Blue...); setting a color name in Bambuddy overrides that. A color is in
|
||||
stock when some spool of it has at least `BAMBUDDY_MIN_GRAMS` (default 50)
|
||||
left.
|
||||
- **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and
|
||||
location ids, and lists `GET /api/v1/items?labels=<id>` (or
|
||||
`locations=<id>`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is
|
||||
@@ -250,8 +253,8 @@ exactly as the note says (a missing `inStock` counts as in stock).
|
||||
| `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. |
|
||||
| `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. |
|
||||
| `BAMBUDDY_URL` | Enables `bambuddy`. |
|
||||
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`, unverified). |
|
||||
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | Sent as `Authorization: Bearer <key>`, or as the raw key in the named header. |
|
||||
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`). |
|
||||
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | The API key from Bambuddy's Settings, sent in `X-API-Key` (or in the named header). |
|
||||
| `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). |
|
||||
| `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. |
|
||||
| `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. |
|
||||
|
||||
@@ -92,7 +92,8 @@ personal Greg Kilmer letterhead, the version without Patiun LLC
|
||||
branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so
|
||||
applicant tracking systems take it in order, aims for one page (roles marked
|
||||
`earlier` print as a short list; skills share a row with education and
|
||||
projects), uses the brand TTFs in
|
||||
projects), and when a one-page resume ends well above the footer it is laid
|
||||
out again with wider gaps (up to twice as wide) so the page reads as full. It uses the brand TTFs in
|
||||
`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change.
|
||||
The highlights row and Selected projects come from the `/resume` Page text note's
|
||||
`stats` and `projects`. Greg's Resume Interview answers live in
|
||||
@@ -111,6 +112,17 @@ The highlights row and Selected projects come from the `/resume` Page text note'
|
||||
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
|
||||
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
|
||||
|
||||
## Filters on world pages
|
||||
|
||||
- **Shelf**: How it plays, Players, Play time, Owned, Want to play, Favorites
|
||||
and Has a blog post, plus four sorts. Players and Play time read the
|
||||
BoardGameGeek sync and only appear once some game has those values. With no
|
||||
filter narrowing the list, the first 12 games show and "Show all" opens the
|
||||
rest.
|
||||
- **Craft pages**: when a craft's makes carry two or more `tags` (Dice, Rings,
|
||||
and so on; `sword rack` does not count), pills above the grid filter them.
|
||||
Makes without a tag fall under Other.
|
||||
|
||||
## Forms
|
||||
|
||||
The Inquiries form posts to `POST /api/inquiries` and the order form to
|
||||
|
||||
@@ -399,6 +399,7 @@ export const productLine = def({
|
||||
channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }),
|
||||
channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(),
|
||||
summary: line({ title: "Summary" }),
|
||||
menuName: line({ title: "Menu name", description: "A short name for the Store menu, like Proxy cards." }).optional(),
|
||||
icon: line({ title: "Icon" }).optional(),
|
||||
order: z.number().int().meta({ title: "Order" }).optional(),
|
||||
slots: z
|
||||
@@ -501,6 +502,11 @@ export const page = def({
|
||||
schema: note("page", {
|
||||
page: line({ title: "Page", description: "The page address this text belongs to, like /about." }),
|
||||
lede: line({ title: "Lede", description: "The one line under the page title." }).optional(),
|
||||
portrait: image({ title: "Portrait", description: "Greg's photo on the landing and About pages." }).optional(),
|
||||
photos: z
|
||||
.array(z.object({ file: image({ title: "Image" }), alt: line({ title: "Alt text" }), caption: line({ title: "Caption" }).optional() }))
|
||||
.meta({ title: "Photos", description: "A row of captioned photos, like Kashi and the tea on About." })
|
||||
.optional(),
|
||||
quickFacts: z
|
||||
.array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) }))
|
||||
.meta({ title: "Quick facts" })
|
||||
|
||||
@@ -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";
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
seed?: string;
|
||||
}
|
||||
const { count = 48, seed = Astro.url.pathname } = Astro.props;
|
||||
const { seed = Astro.url.pathname } = Astro.props;
|
||||
const base = seedOf(seed);
|
||||
---
|
||||
<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>
|
||||
.gk-dust {
|
||||
@@ -16,4 +23,16 @@ const { count = 48, seed = Astro.url.pathname } = Astro.props;
|
||||
inset: 0;
|
||||
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>
|
||||
|
||||
@@ -12,7 +12,7 @@ import ConstellationDust from "./ConstellationDust.astro";
|
||||
import EdgeSeals from "./EdgeSeals.astro";
|
||||
import SiteFooter from "./SiteFooter.astro";
|
||||
import SiteHeader from "./SiteHeader.astro";
|
||||
import type { NavKey } from "./nav.ts";
|
||||
import type { NavKey, StoreNav } from "./nav.ts";
|
||||
import type { PageFamily, SealName } from "./seals.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -22,10 +22,12 @@ interface Props {
|
||||
/** A craft page's own seal, overriding the family's. */
|
||||
seal?: SealName;
|
||||
current?: NavKey;
|
||||
/** The Store's own header, on Store pages. */
|
||||
store?: StoreNav;
|
||||
/** Leave the edge seals off, for a page led by a photo. */
|
||||
noSeals?: boolean;
|
||||
}
|
||||
const { title, description, family = "landing", seal, current, noSeals } = Astro.props;
|
||||
const { title, description, family = "landing", seal, current, store, noSeals } = Astro.props;
|
||||
const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
---
|
||||
<!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="color-scheme" content="dark" />
|
||||
<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>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<slot name="head" />
|
||||
@@ -44,7 +50,7 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
<div class="gk-page">
|
||||
<ConstellationDust />
|
||||
{!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">
|
||||
<slot />
|
||||
</main>
|
||||
@@ -58,6 +64,11 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
/* The page's nebula: violet from the top right, teal from the bottom left. */
|
||||
background:
|
||||
radial-gradient(ellipse at 100% 0%, rgba(106, 47, 224, 0.28), transparent 50%),
|
||||
radial-gradient(ellipse at 0% 100%, rgba(20, 163, 154, 0.2), transparent 55%),
|
||||
var(--gk-sky);
|
||||
}
|
||||
.gk-main {
|
||||
position: relative;
|
||||
|
||||
@@ -2,25 +2,49 @@
|
||||
/**
|
||||
* The site header: nebula and filament banner behind the primary nav, an
|
||||
* optional page hero (the `hero` slot), and the header divider. The filament
|
||||
* banner and divider appear here only, never elsewhere on a page.
|
||||
* banner and divider appear here only, never elsewhere on a page. Store pages
|
||||
* pass `store` and get the Store's own header: Store.GregKilmer.com, its
|
||||
* product lines, Order request and a way back to the main site.
|
||||
*/
|
||||
import monogram from "../assets/brand/gk-monogram.svg?url";
|
||||
import Divider from "./Divider.astro";
|
||||
import Star from "./Star.astro";
|
||||
import { main, tail, world, type NavKey } from "./nav.ts";
|
||||
import { main, tail, world, type NavKey, type StoreNav } from "./nav.ts";
|
||||
import { filamentBanner } from "./seals.ts";
|
||||
|
||||
interface Props {
|
||||
/** Which nav entry the page belongs to. */
|
||||
current?: NavKey;
|
||||
store?: StoreNav;
|
||||
/** Whether the page has a hero; PageShell always fills the slot, so it says. */
|
||||
hero?: boolean;
|
||||
}
|
||||
const { current } = Astro.props;
|
||||
const { current, store, hero = Astro.slots.has("default") } = Astro.props;
|
||||
const inWorld = world.some((w) => w.key === current);
|
||||
const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
---
|
||||
<header class="gk-site-header">
|
||||
<div class="nebula" aria-hidden="true"></div>
|
||||
<img src={filamentBanner} alt="" aria-hidden="true" class="banner" />
|
||||
<div class="decor" aria-hidden="true">
|
||||
<div class="nebula"></div>
|
||||
<img src={filamentBanner} alt="" class="banner" />
|
||||
</div>
|
||||
{
|
||||
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" />
|
||||
@@ -53,28 +77,37 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
{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") && (
|
||||
hero && (
|
||||
<div class="gk-wrap hero">
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="gk-wrap divider"><Divider /></div>
|
||||
{hero && <div class="gk-wrap divider"><Divider /></div>}
|
||||
</header>
|
||||
|
||||
<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 {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
color: var(--gk-text);
|
||||
isolation: isolate;
|
||||
}
|
||||
.decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.nebula {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
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%);
|
||||
@@ -88,8 +121,6 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
width: 900px;
|
||||
max-width: none;
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.nav {
|
||||
max-width: var(--gk-page-max);
|
||||
@@ -114,6 +145,17 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
.mono {
|
||||
width: 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 {
|
||||
display: flex;
|
||||
@@ -126,6 +168,15 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
|
||||
letter-spacing: 0.14em;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,3 +17,9 @@ export const tail: { key: NavKey; label: string; href: string }[] = [
|
||||
{ key: "blog", label: "Blog", href: "/blog" },
|
||||
{ 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;
|
||||
}
|
||||
|
||||
/* A component's own display never un-hides what a script hid. */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
-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 pre { margin: 0; white-space: pre-wrap; }
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 class ContentApi {
|
||||
@@ -172,6 +181,20 @@ export class ContentApi {
|
||||
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
||||
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. */
|
||||
attachment = (name: string) =>
|
||||
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||
|
||||
@@ -12,6 +12,8 @@ const nav: [string, string][] = [
|
||||
["/inbox", "Inbox"],
|
||||
["/notes", "Notes"],
|
||||
["/drafts", "Drafts"],
|
||||
["/posts", "Posts"],
|
||||
["/images", "Images"],
|
||||
["/capture", "Capture"],
|
||||
["/store", "Store"],
|
||||
["/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="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">
|
||||
<script src="/admin.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<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 * as pages from "./pages.ts";
|
||||
import * as inbox from "./inbox.ts";
|
||||
import * as posts from "./posts.ts";
|
||||
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
||||
|
||||
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 {
|
||||
constructor(readonly to: string) {}
|
||||
}
|
||||
|
||||
type Form = URLSearchParams;
|
||||
type Upload = { field: string; name: string; bytes: Uint8Array<ArrayBuffer> };
|
||||
type Page = { title: string; body: Html };
|
||||
|
||||
export function createAdminServer(api: ContentApi, opts: { now?: () => Date } = {}) {
|
||||
@@ -112,6 +118,19 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
}
|
||||
case "/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") {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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": {
|
||||
const r = await api.scan();
|
||||
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(
|
||||
"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") {
|
||||
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "max-age=300" });
|
||||
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") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end('{"ok":true}');
|
||||
@@ -296,11 +364,15 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
|
||||
if (method === "POST") {
|
||||
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
||||
const form = new URLSearchParams(await readBody(req));
|
||||
const r = await post(url.pathname, form);
|
||||
const { form, files } = await readForm(req);
|
||||
const r = await post(url.pathname, form, files);
|
||||
if (r instanceof Redirect) {
|
||||
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");
|
||||
|
||||
const out = await get(url.pathname, url.searchParams);
|
||||
@@ -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[] = [];
|
||||
let size = 0;
|
||||
for await (const c of req) {
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,11 @@ SUWAYOMI_PASS=
|
||||
# PersonalReader, light novels and books (daily).
|
||||
KAVITA_URL=
|
||||
KAVITA_API_KEY=
|
||||
# Filament in stock (hourly). UNVERIFIED API: check the path and mapping first.
|
||||
# Filament in stock (hourly).
|
||||
BAMBUDDY_URL=
|
||||
# BAMBUDDY_SPOOLS_PATH=/api/v1/inventory/spools
|
||||
# BAMBUDDY_API_KEY=
|
||||
# API key from Bambuddy's Settings, sent in X-API-Key
|
||||
BAMBUDDY_API_KEY=
|
||||
# BAMBUDDY_AUTH_HEADER=
|
||||
# BAMBUDDY_GROUP=Filament
|
||||
# BAMBUDDY_MATERIALS=PLA,PETG
|
||||
|
||||
@@ -15,9 +15,12 @@ import type { Change, ContentService } from "./service.ts";
|
||||
import type { Author, StoredNote } from "./store.ts";
|
||||
import { handleSubmissions } from "./submissions.ts";
|
||||
import { handleSuggestions } from "./suggestions.ts";
|
||||
import { uploadTypes } from "./vault.ts";
|
||||
import { heldView, noteView, outcomeView, revisionView, summaryView } from "./views.ts";
|
||||
|
||||
const MAX_BODY = 1_000_000;
|
||||
/** The largest image the admin page can add. */
|
||||
const MAX_IMAGE = 15_000_000;
|
||||
|
||||
class HttpError extends Error {
|
||||
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);
|
||||
|
||||
/** 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) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
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 });
|
||||
}
|
||||
|
||||
if (path === "/api/attachments") {
|
||||
need(can.review(role));
|
||||
return send(res, 200, attachmentList());
|
||||
}
|
||||
|
||||
const attachment = path.match(/^\/attachments\/([^/]+)$/);
|
||||
if (attachment) {
|
||||
let name = "";
|
||||
@@ -250,6 +267,34 @@ export function createHttpServer(service: ContentService, config: Pick<Config, "
|
||||
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") {
|
||||
need(can.review(role));
|
||||
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) {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body));
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Bambuddy: filament colors in stock, for the Store's Filament options.
|
||||
* Hourly. Read only.
|
||||
*
|
||||
* UNVERIFIED: Bambuddy's API is not documented anywhere this project has, and
|
||||
* no request has been made to a real Bambuddy. The endpoint path is a setting
|
||||
* (BAMBUDDY_SPOOLS_PATH) and everything that depends on the response's shape
|
||||
* is in `mapBambuddySpools` below, so fixing it against the real API is a
|
||||
* change to one function. Check it against a live Bambuddy before relying on
|
||||
* it (docs/content-service.md, "Sync jobs").
|
||||
* Hourly. Read only. GET /api/v1/inventory/spools with an API key (made in
|
||||
* Bambuddy's Settings) in the X-API-Key header. The field names below were
|
||||
* checked against Greg's Bambuddy on 2026-10-12.
|
||||
*/
|
||||
import type { SyncJob } from "../types.ts";
|
||||
import { mergeOptions, normalizeHex, storeOnLines, type StockOption } from "./stock.ts";
|
||||
@@ -18,7 +13,7 @@ export interface BambuddyOptions {
|
||||
url: string;
|
||||
/** Path of the spool or filament inventory endpoint. */
|
||||
path?: string;
|
||||
/** Sent as `Authorization: Bearer`, or in `authHeader` when that is set. */
|
||||
/** Sent in the X-API-Key header, or in `authHeader` when that is set. */
|
||||
apiKey?: string;
|
||||
authHeader?: string;
|
||||
/** Option group the colors feed. Default Filament. */
|
||||
@@ -39,40 +34,58 @@ export interface Spool {
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
const pick = (o: Record<string, unknown>, keys: string[]) => {
|
||||
for (const k of keys) {
|
||||
const v = o[k];
|
||||
if (v !== undefined && v !== null && v !== "") return v;
|
||||
/** Plain names for spools Bambuddy leaves unnamed (RFID spools have no color_name). */
|
||||
const BASIC_COLORS: [string, number, number, number][] = [
|
||||
["Black", 0, 0, 0],
|
||||
["White", 255, 255, 255],
|
||||
["Gray", 128, 128, 128],
|
||||
["Silver", 192, 192, 192],
|
||||
["Red", 200, 30, 40],
|
||||
["Orange", 255, 120, 0],
|
||||
["Yellow", 255, 210, 0],
|
||||
["Green", 0, 160, 70],
|
||||
["Blue", 20, 90, 200],
|
||||
["Purple", 120, 40, 160],
|
||||
["Pink", 240, 110, 170],
|
||||
["Brown", 110, 70, 40],
|
||||
["Beige", 240, 225, 190],
|
||||
];
|
||||
|
||||
/** The nearest of BASIC_COLORS to a #rrggbb hex. */
|
||||
export function basicColorName(hex: string): string {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
|
||||
let best = BASIC_COLORS[0]!;
|
||||
let bestD = Infinity;
|
||||
for (const c of BASIC_COLORS) {
|
||||
const d = (c[1] - r) ** 2 + (c[2] - g) ** 2 + (c[3] - b) ** 2;
|
||||
if (d < bestD) [best, bestD] = [c, d];
|
||||
}
|
||||
return best[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE ONE PLACE that knows Bambuddy's response shape (a guess; see above).
|
||||
* Accepts a list, or an object holding one under items, spools, data or
|
||||
* results, and reads each spool's color name, hex, material and grams left
|
||||
* under the common spellings. Archived or empty-named entries are skipped.
|
||||
* THE ONE PLACE that knows Bambuddy's spool shape. The response is a list (or
|
||||
* an object holding one under items). Each spool has material, color_name
|
||||
* (often null), rgba like "000000FF", label_weight (grams of filament when
|
||||
* full), weight_used and archived_at. A spool with no color_name is named
|
||||
* from its color (Black, Blue...); a color name set in Bambuddy wins.
|
||||
*/
|
||||
export function mapBambuddySpools(json: unknown): Spool[] {
|
||||
const root = json as Record<string, unknown> | unknown[];
|
||||
const list = Array.isArray(root)
|
||||
? root
|
||||
: (["items", "spools", "data", "results"].map((k) => (root as Record<string, unknown>)?.[k]).find(Array.isArray) as unknown[] | undefined);
|
||||
if (!list) throw new Error("Bambuddy answered with no list of spools");
|
||||
const list = Array.isArray(json) ? json : (json as Record<string, unknown> | null)?.items;
|
||||
if (!Array.isArray(list)) throw new Error("Bambuddy answered with no list of spools");
|
||||
const out: Spool[] = [];
|
||||
for (const raw of list) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (o.archived === true || o.deleted === true) continue;
|
||||
const name = pick(o, ["color_name", "colorName", "color", "name"]);
|
||||
if (typeof name !== "string" || !name.trim()) continue;
|
||||
const s: Spool = { name: name.trim() };
|
||||
const hex = normalizeHex(pick(o, ["color_hex", "colorHex", "hex", "rgba", "color_code"]));
|
||||
if (o.archived_at) continue;
|
||||
const hex = normalizeHex(o.rgba);
|
||||
const named = typeof o.color_name === "string" && o.color_name.trim() ? o.color_name.trim() : undefined;
|
||||
const name = named ?? (hex ? basicColorName(hex) : undefined);
|
||||
if (!name) continue;
|
||||
const s: Spool = { name };
|
||||
if (hex) s.hex = hex;
|
||||
const material = pick(o, ["material", "filament_type", "type"]);
|
||||
if (typeof material === "string") s.material = material.trim();
|
||||
const grams = Number(pick(o, ["remaining_weight", "remainingWeight", "weight_remaining", "remaining", "remaining_grams"]));
|
||||
if (Number.isFinite(grams)) s.remaining = grams;
|
||||
if (typeof o.material === "string") s.material = o.material.trim();
|
||||
if (typeof o.label_weight === "number") s.remaining = o.label_weight - (typeof o.weight_used === "number" ? o.weight_used : 0);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
@@ -97,10 +110,10 @@ export function bambuddyJob(o: BambuddyOptions): SyncJob {
|
||||
healthcheckUrl: o.healthcheckUrl,
|
||||
async run(ctx) {
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
if (o.apiKey) headers[o.authHeader?.toLowerCase() || "authorization"] = o.authHeader ? o.apiKey : `Bearer ${o.apiKey}`;
|
||||
if (o.apiKey) headers[(o.authHeader || "x-api-key").toLowerCase()] = o.apiKey;
|
||||
const url = `${o.url.replace(/\/$/, "")}/${(o.path ?? BAMBUDDY_DEFAULT_PATH).replace(/^\//, "")}`;
|
||||
const res = await ctx.fetch(url, { headers });
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY`);
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY (made in Bambuddy's Settings)`);
|
||||
if (!res.ok) throw new Error(`Bambuddy answered ${res.status}; check BAMBUDDY_SPOOLS_PATH`);
|
||||
const options = filamentOptions(mapBambuddySpools(await res.json()), o);
|
||||
const lines = storeOnLines(ctx, "filamentStock", [{ group, options }]);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* API (Homebox's REST API, v1):
|
||||
* - `POST /api/v1/users/login` with username and password gives a token
|
||||
* (already prefixed `Bearer `), sent as `Authorization`.
|
||||
* - `GET /api/v1/labels` and `GET /api/v1/locations` name the ids to filter on.
|
||||
* - `GET /api/v1/labels` (`/api/v1/tags` on newer releases) and `GET /api/v1/locations`
|
||||
* name the ids to filter on.
|
||||
* - `GET /api/v1/items?labels=<id>&page=&pageSize=` (or `locations=<id>`)
|
||||
* lists the items, each with name, quantity and description.
|
||||
*
|
||||
@@ -84,7 +85,10 @@ export function materialOptions(items: HomeboxItem[]): StockOption[] {
|
||||
}
|
||||
|
||||
async function call(ctx: SyncContext, url: string, init: RequestInit = {}) {
|
||||
const res = await ctx.fetch(url, init);
|
||||
return checked(await ctx.fetch(url, init));
|
||||
}
|
||||
|
||||
async function checked(res: Response) {
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Homebox refused the request (${res.status}); check HOMEBOX_USER and HOMEBOX_PASS`);
|
||||
if (!res.ok) throw new Error(`Homebox answered ${res.status}`);
|
||||
return res.json() as Promise<unknown>;
|
||||
@@ -110,9 +114,14 @@ export function homeboxJob(o: HomeboxOptions): SyncJob {
|
||||
...(o.locations ?? []).map((f) => ({ kind: "locations" as const, f })),
|
||||
];
|
||||
const ids = { labels: new Map<string, string>(), locations: new Map<string, string>() };
|
||||
for (const kind of ["labels", "locations"] as const) {
|
||||
if (filters.some((x) => x.kind === kind)) ids[kind] = parseNamed(await call(ctx, api(`/${kind}`), { headers }));
|
||||
// Newer Homebox releases call labels "tags" (`/api/v1/tags`, `items?tags=`).
|
||||
let labelParam = "labels";
|
||||
if (filters.some((x) => x.kind === "labels")) {
|
||||
const res = await ctx.fetch(api("/labels"), { headers });
|
||||
if (res.status === 404) labelParam = "tags";
|
||||
ids.labels = parseNamed(res.status === 404 ? await call(ctx, api("/tags"), { headers }) : await checked(res));
|
||||
}
|
||||
if (filters.some((x) => x.kind === "locations")) ids.locations = parseNamed(await call(ctx, api("/locations"), { headers }));
|
||||
|
||||
const byGroup = new Map<string, HomeboxItem[]>();
|
||||
const unknown: string[] = [];
|
||||
@@ -124,7 +133,7 @@ export function homeboxJob(o: HomeboxOptions): SyncJob {
|
||||
}
|
||||
const items = byGroup.get(f.group) ?? [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const p = new URLSearchParams({ [kind]: id, page: String(page), pageSize: "100" });
|
||||
const p = new URLSearchParams({ [kind === "labels" ? labelParam : kind]: id, page: String(page), pageSize: "100" });
|
||||
const r = parseItems(await call(ctx, api(`/items?${p}`), { headers }));
|
||||
items.push(...r.items);
|
||||
if (r.items.length < 100 || page * 100 >= r.total) break;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The Site folder on disk. Only the content service reads or writes it.
|
||||
*/
|
||||
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";
|
||||
|
||||
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
@@ -22,6 +22,42 @@ export function isNotePath(rel: string): boolean {
|
||||
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 {
|
||||
constructor(readonly dir: string) {}
|
||||
|
||||
@@ -77,6 +113,56 @@ export class Vault {
|
||||
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. */
|
||||
attachmentPath(name: 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", () => {
|
||||
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" });
|
||||
|
||||
@@ -17,6 +17,6 @@ and ids, names and image addresses are illustrative.
|
||||
| `anilist-page.sample.json` | `POST https://graphql.anilist.co` with the batched `Page { media(id_in:) }` query |
|
||||
| `kavita-series.sample.json` | `POST <KAVITA_URL>/api/Series/all-v2` (SeriesDto list) |
|
||||
| `kavita-metadata.sample.json` | `GET <KAVITA_URL>/api/Series/metadata?seriesId=`, one answer per series id |
|
||||
| `bambuddy-spools.sample.json` | **A guess.** Bambuddy's API is not documented anywhere this project has; this only exercises `mapBambuddySpools`' tolerant reading. |
|
||||
| `bambuddy-spools.sample.json` | GET `/api/v1/inventory/spools`, fields copied from one spool on Greg's Bambuddy (2026-10-12); the other spools are made up. |
|
||||
| `homebox-labels.sample.json` | `GET <HOMEBOX_URL>/api/v1/labels` |
|
||||
| `homebox-items.sample.json` | `GET <HOMEBOX_URL>/api/v1/items?labels=…` |
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"_comment": "GUESSED SHAPE. Bambuddy's API is undocumented here; this only exercises mapBambuddySpools' tolerant reading.",
|
||||
"items": [
|
||||
{ "id": 1, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DCFF", "remaining_weight": 640 },
|
||||
{ "id": 2, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DC", "remaining_weight": 20 },
|
||||
{ "id": 3, "material": "PLA", "colorName": "Bambu Green", "colorHex": "#00AE42", "remainingWeight": 15 },
|
||||
{ "id": 4, "material": "PETG", "color": "Translucent Orange", "hex": "#FF6A13", "remaining": 900 },
|
||||
{ "id": 5, "material": "PLA", "color_name": "Old Spool", "archived": true, "remaining_weight": 500 },
|
||||
{ "id": 6, "material": "PLA", "color_name": "Charcoal", "color_hex": "#2b2b2b" }
|
||||
[
|
||||
{ "id": 20, "material": "PLA", "subtype": "Tough+", "color_name": null, "rgba": "000000FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 6.07, "archived_at": null },
|
||||
{ "id": 21, "material": "PLA", "subtype": "Basic", "color_name": "Jade White", "rgba": "F5F5DCFF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 360, "archived_at": null },
|
||||
{ "id": 22, "material": "PLA", "subtype": "Basic", "color_name": "Jade White", "rgba": "F5F5DCFF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 980, "archived_at": null },
|
||||
{ "id": 23, "material": "PLA", "subtype": "Basic", "color_name": "Bambu Green", "rgba": "00AE42FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 985, "archived_at": null },
|
||||
{ "id": 24, "material": "PETG", "subtype": "Translucent", "color_name": "Translucent Orange", "rgba": "FF6A13FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 100, "archived_at": null },
|
||||
{ "id": 25, "material": "PLA", "subtype": "Basic", "color_name": "Old Spool", "rgba": "808080FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 0, "archived_at": "2026-09-01T00:00:00" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The homelab jobs (Suwayomi, Kavita, Bambuddy, Homebox) and Library
|
||||
* suggestions, against the hand-written sample payloads in ./fixtures. Those
|
||||
* are documented shapes (Bambuddy's is a guess), never live responses; see
|
||||
* are documented shapes, never live responses; see
|
||||
* fixtures/README.md.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { parseTokens } from "../../src/config.ts";
|
||||
import { createHttpServer } from "../../src/http.ts";
|
||||
import { listSuggestions } from "../../src/suggestions.ts";
|
||||
import { bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts";
|
||||
import { basicColorName, bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts";
|
||||
import { homelabSettings } from "../../src/sync/jobs/homelab.ts";
|
||||
import { homeboxJob, materialOptions, parseFilters, parseItems } from "../../src/sync/jobs/homebox.ts";
|
||||
import { kavitaJob, kavitaState, parseMetadata, parseSeries } from "../../src/sync/jobs/kavita.ts";
|
||||
@@ -284,18 +284,20 @@ describe("Kavita", () => {
|
||||
|
||||
// ---------------------------------------------------------------- Bambuddy
|
||||
|
||||
describe("Bambuddy (unverified API shape)", () => {
|
||||
it("maps spools to colors: merged by name, enough grams is in stock, archived skipped", () => {
|
||||
describe("Bambuddy", () => {
|
||||
it("maps spools to colors: unnamed spools named by color, merged by name, grams left decide stock, archived skipped", () => {
|
||||
const spools = mapBambuddySpools(fixture("bambuddy-spools.sample.json"));
|
||||
expect(spools.map((s) => s.name)).toEqual(["Jade White", "Jade White", "Bambu Green", "Translucent Orange", "Charcoal"]);
|
||||
expect(spools.map((s) => s.name)).toEqual(["Black", "Jade White", "Jade White", "Bambu Green", "Translucent Orange"]);
|
||||
expect(spools[0]).toEqual({ name: "Black", hex: "#000000", material: "PLA", remaining: 1000 - 6.07 });
|
||||
expect(filamentOptions(spools)).toEqual([
|
||||
{ name: "Charcoal", hex: "#2b2b2b", inStock: true },
|
||||
{ name: "Black", hex: "#000000", inStock: true },
|
||||
{ name: "Jade White", hex: "#f5f5dc", inStock: true },
|
||||
{ name: "Translucent Orange", hex: "#ff6a13", inStock: true },
|
||||
{ name: "Bambu Green", hex: "#00ae42", inStock: false },
|
||||
]);
|
||||
expect(filamentOptions(spools, { materials: ["PETG"] }).map((o) => o.name)).toEqual(["Translucent Orange"]);
|
||||
expect(mapBambuddySpools([{ name: "Plain list" }])).toEqual([{ name: "Plain list" }]);
|
||||
expect(mapBambuddySpools({ items: [{ color_name: "Wrapped" }] })).toEqual([{ name: "Wrapped" }]);
|
||||
expect(basicColorName("#1e3a8a")).toBe("Blue");
|
||||
expect(() => mapBambuddySpools({ nothing: true })).toThrow(/no list/);
|
||||
});
|
||||
|
||||
@@ -316,10 +318,10 @@ describe("Bambuddy (unverified API shape)", () => {
|
||||
const cards = r.put(line("Cards", "Finish"));
|
||||
r.service.scan();
|
||||
const f = fakeFetch(() => ok(fixture("bambuddy-spools.sample.json")));
|
||||
const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", path: "/api/v1/spools", apiKey: "k" }), f.fn).runNow("bambuddy");
|
||||
const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", apiKey: "k" }), f.fn).runNow("bambuddy");
|
||||
expect(report).toMatchObject({ ok: true, counts: { colors: 4, inStock: 3, lines: 1 } });
|
||||
expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/spools");
|
||||
expect(headerOf(f.calls[0]!, "authorization")).toBe("Bearer k");
|
||||
expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/inventory/spools");
|
||||
expect(headerOf(f.calls[0]!, "x-api-key")).toBe("k");
|
||||
expect(r.store.syncCache.forPath(printed).filamentStock).toEqual([{ group: "Filament", options: expect.arrayContaining([{ name: "Jade White", hex: "#f5f5dc", inStock: true }]) }]);
|
||||
expect(r.store.syncCache.forPath(cards)).toEqual({});
|
||||
// Notes are never written.
|
||||
@@ -375,6 +377,32 @@ describe("Homebox", () => {
|
||||
const missing = await scheduler(homeboxJob({ url: "http://homebox:7745", user: "u", pass: "p", labels: parseFilters("vinyl") }), f.fn).runNow("homebox");
|
||||
expect(missing).toMatchObject({ ok: false, message: expect.stringContaining("vinyl") });
|
||||
});
|
||||
|
||||
it("falls back to tags on Homebox releases without /labels", async () => {
|
||||
r.put({
|
||||
type: "product-line",
|
||||
title: "Wallet",
|
||||
category: "Leather",
|
||||
mode: "made to order",
|
||||
channel: "here",
|
||||
summary: "s",
|
||||
slots: { open: 1, total: 2 },
|
||||
priceTiers: [],
|
||||
options: [{ group: "Leather", name: "Chrome tan, Black" }],
|
||||
publish: true,
|
||||
});
|
||||
r.service.scan();
|
||||
const f = fakeFetch((url) => {
|
||||
if (url.endsWith("/api/v1/users/login")) return ok({ token: "Bearer hb-token", expiresAt: "2026-10-12T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/tags")) return ok(fixture("homebox-labels.sample.json"));
|
||||
if (url.includes("/api/v1/items?")) {
|
||||
return new URL(url).searchParams.get("tags")?.endsWith("01") ? ok(fixture("homebox-items.sample.json")) : ok({ items: [], total: 0 });
|
||||
}
|
||||
return ok({}, 404);
|
||||
});
|
||||
const report = await scheduler(homeboxJob({ url: "http://homebox:7745", user: "u", pass: "p", labels: parseFilters("leather,paper") }), f.fn).runNow("homebox");
|
||||
expect(report).toMatchObject({ ok: true, counts: { lines: 1, leather: 1, paper: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- settings
|
||||
|
||||
|
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
|
||||
* star-chart art stands in; pass `src` once there is one.
|
||||
* Greg's portrait in a gold frame, from the Page text note's `portrait`.
|
||||
* Without one, or if it fails to load, the star-chart art stands in.
|
||||
*/
|
||||
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 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]}>
|
||||
<StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" />
|
||||
{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>}
|
||||
</figure>
|
||||
|
||||
@@ -38,6 +41,20 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
|
||||
.hero :global(.art) {
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
@@ -50,4 +67,7 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.panel :global(.photo) {
|
||||
object-position: 50% 25%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
/**
|
||||
* The favorites constellation: round covers strung on a dotted gold line,
|
||||
* each linking to its row in the list.
|
||||
* each linking to its row in the list. A favorite without a cover gets
|
||||
* star-chart art in its circle.
|
||||
*/
|
||||
import Star from "@gk/ui/components/Star.astro";
|
||||
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||
import TickScale from "@gk/ui/components/TickScale.astro";
|
||||
import { mediaSrc } from "../../lib/site.ts";
|
||||
import type { Item } from "../../lib/library.ts";
|
||||
@@ -35,7 +37,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
|
||||
items.map((item, i) => (
|
||||
<li style={`--lift:${lift[i % lift.length]}px`}>
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
@@ -104,18 +106,32 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
img {
|
||||
a :global(.orb) {
|
||||
flex: none;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--gk-midnight);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--gk-gold),
|
||||
0 0 22px rgba(201, 162, 74, 0.45);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
a:hover img {
|
||||
a :global(.orb .mat) {
|
||||
display: none;
|
||||
}
|
||||
a :global(.orb .seal) {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
a :global(.orb .photo) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
a:hover :global(.orb) {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--gk-gold-bright),
|
||||
0 0 30px rgba(201, 162, 74, 0.7);
|
||||
@@ -137,7 +153,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
|
||||
li {
|
||||
padding-top: 0;
|
||||
}
|
||||
img {
|
||||
a :global(.orb) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||
import StatusPill from "@gk/ui/components/StatusPill.astro";
|
||||
import StatusChip from "../StatusChip.astro";
|
||||
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
|
||||
import { gamePlayers, gamePlayTime, type Game } from "../../lib/library.ts";
|
||||
import { gamePlayers, gamePlayTime, minutesOf, playersOf, type Game } from "../../lib/library.ts";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
@@ -24,6 +24,8 @@ const plays = d.howItPlays ?? [];
|
||||
const status = site.statusFor(game);
|
||||
// Players and play time sync from BoardGameGeek; nothing shows until they have.
|
||||
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
|
||||
const seats = playersOf(game);
|
||||
const hasPost = site.backlinks(game, { type: "post" }).length > 0;
|
||||
---
|
||||
<li
|
||||
id={game.slug}
|
||||
@@ -34,6 +36,9 @@ const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
|
||||
data-f={d.favorite ? "1" : undefined}
|
||||
data-lp={d.lastPlayed}
|
||||
data-c={d.created}
|
||||
data-pl={seats?.join("-")}
|
||||
data-t={minutesOf(game)}
|
||||
data-b={hasPost ? "1" : undefined}
|
||||
hidden={hidden}
|
||||
>
|
||||
<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 {
|
||||
title: string;
|
||||
note?: string;
|
||||
@@ -7,25 +10,32 @@ interface Props {
|
||||
const { title, note } = Astro.props;
|
||||
---
|
||||
<aside class="cta" aria-label={title}>
|
||||
<div class="inner">
|
||||
<div class="words">
|
||||
<p class="t">{title}</p>
|
||||
{note && <p class="n">{note}</p>}
|
||||
</div>
|
||||
<div class="btns"><slot /></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.cta {
|
||||
margin-inline: calc(50% - 50vw);
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
}
|
||||
.words {
|
||||
display: flex;
|
||||
@@ -50,8 +60,8 @@ const { title, note } = Astro.props;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.cta {
|
||||
padding: 24px 20px;
|
||||
.inner {
|
||||
padding-block: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -128,6 +128,7 @@ export const sorts = [
|
||||
{ key: "played", label: "Last played" },
|
||||
{ key: "az", label: "A to Z" },
|
||||
{ key: "added", label: "Recently added" },
|
||||
{ key: "time", label: "Play time" },
|
||||
] as const;
|
||||
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),
|
||||
az,
|
||||
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]);
|
||||
}
|
||||
@@ -148,9 +151,29 @@ export interface ShelfFilter {
|
||||
owned: boolean;
|
||||
want: boolean;
|
||||
favorites: boolean;
|
||||
blog: boolean;
|
||||
players: PlayersKey | "";
|
||||
time: TimeKey | "";
|
||||
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 {
|
||||
const submitted = params.has("f");
|
||||
return {
|
||||
@@ -159,19 +182,27 @@ export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
||||
owned: submitted ? params.get("owned") === "1" : true,
|
||||
want: params.get("want") === "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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 want = !!g.data.wantToPlay;
|
||||
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) 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;
|
||||
return !f.q || text.includes(f.q);
|
||||
}
|
||||
@@ -185,6 +216,30 @@ interface GameSynced {
|
||||
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 || ""));
|
||||
|
||||
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
|
||||
|
||||
@@ -43,7 +43,21 @@ interface Col {
|
||||
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.
|
||||
*/
|
||||
export 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({
|
||||
size: PAGE.size,
|
||||
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 chunks: Buffer[] = [];
|
||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
let pages = 1;
|
||||
let end = 0;
|
||||
const done = new Promise<Body>((resolve, reject) => {
|
||||
doc.on("end", () => resolve({ pdf: Buffer.concat(chunks), pages, end }));
|
||||
doc.on("error", reject);
|
||||
});
|
||||
|
||||
@@ -73,7 +89,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
|
||||
const section = (title: string, c: Col = full) => {
|
||||
room(50);
|
||||
doc.moveDown(0.4);
|
||||
gapDown(0.4);
|
||||
const y = doc.y;
|
||||
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;
|
||||
@@ -85,16 +101,16 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
const indent = 11;
|
||||
const w = c.w - indent;
|
||||
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;
|
||||
diamond(c.x + 3.5, y + 5.5, 2.1);
|
||||
if (lead) {
|
||||
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1 });
|
||||
doc.font("body").text(` ${text}`, { 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: lg(1) });
|
||||
} 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. */
|
||||
@@ -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 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);
|
||||
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(" · ");
|
||||
doc.text(links, full.x, y, { width: full.w - 120 });
|
||||
}
|
||||
doc.moveDown(0.6);
|
||||
gapDown(0.6);
|
||||
|
||||
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) {
|
||||
doc.moveDown(0.6);
|
||||
gapDown(0.6);
|
||||
const cols = Math.min(r.stats.length, 4);
|
||||
const colW = full.w / cols;
|
||||
const top = doc.y;
|
||||
@@ -156,14 +172,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
section("Experience");
|
||||
r.roles.forEach((role, i) => {
|
||||
const d = role.data;
|
||||
if (i > 0) doc.moveDown(0.5);
|
||||
if (i > 0) gapDown(0.5);
|
||||
titled(d.role, period(role), 10.5);
|
||||
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 });
|
||||
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.
|
||||
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);
|
||||
doc.x = full.x;
|
||||
});
|
||||
@@ -173,7 +189,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
section("Earlier");
|
||||
r.earlier.forEach((role, i) => {
|
||||
const d = role.data;
|
||||
if (i > 0) doc.moveDown(0.3);
|
||||
if (i > 0) gapDown(0.3);
|
||||
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 });
|
||||
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");
|
||||
const lh = doc.heightOfString(g.label, { width: labelW });
|
||||
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);
|
||||
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
|
||||
doc.font("body");
|
||||
const rightH =
|
||||
(entries ? 24 + entries * 26 : 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();
|
||||
const start = doc.y;
|
||||
let leftEnd = start;
|
||||
@@ -211,7 +227,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
const y = doc.y;
|
||||
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
|
||||
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;
|
||||
}
|
||||
leftEnd = doc.y;
|
||||
@@ -224,12 +240,12 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
for (const dg of edu?.degrees ?? []) {
|
||||
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.moveDown(0.3);
|
||||
gapDown(0.3);
|
||||
}
|
||||
for (const c of edu?.certifications ?? []) {
|
||||
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 });
|
||||
doc.moveDown(0.3);
|
||||
gapDown(0.3);
|
||||
}
|
||||
}
|
||||
if (r.projects.length) {
|
||||
@@ -239,8 +255,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
doc.x = full.x;
|
||||
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.
|
||||
const range = doc.bufferedPageRange();
|
||||
pages = range.count;
|
||||
if (range.count > 1) {
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
@@ -257,9 +275,38 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
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. One that just spills onto a second
|
||||
* page is laid out with gaps down to 70% when that brings it back to one.
|
||||
* Longer resumes are left as they are.
|
||||
*/
|
||||
export async function fitBody(r: ResumeData): Promise<Body> {
|
||||
const first = await renderBody(r);
|
||||
if (first.pages > 1) {
|
||||
for (const spread of [0.9, 0.8, 0.7]) {
|
||||
const tight = await renderBody(r, spread);
|
||||
if (tight.pages === 1) return tight;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
if (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. */
|
||||
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();
|
||||
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
|
||||
out.setAuthor(r.name);
|
||||
|
||||
@@ -164,3 +164,12 @@ const CATEGORY_ICONS: Record<Category, string> = { Tabletop: "dice", Paper: "boo
|
||||
export function lineIcon(line: Line): string {
|
||||
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 Icon from "@gk/ui/components/Icon.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 Star from "@gk/ui/components/Star.astro";
|
||||
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||
import TickScale from "@gk/ui/components/TickScale.astro";
|
||||
import PageHero from "../components/PageHero.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 { brandIcon } from "../lib/site.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 text = s.pageText("/about");
|
||||
@@ -25,6 +26,7 @@ const settings = s.settings();
|
||||
const { intro, sections } = splitIntro(text?.body ?? "");
|
||||
const introHtml = s.render(intro);
|
||||
const facts = text?.data.quickFacts ?? [];
|
||||
const photos = text?.data.photos ?? [];
|
||||
const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st));
|
||||
const nowUpdated = now.map((v) => v.date).sort().pop();
|
||||
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>}
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -60,6 +62,18 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
||||
</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>
|
||||
|
||||
<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
|
||||
</SectionHeading>
|
||||
<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 featured">
|
||||
<GiltCorners inset={6} size={30} />
|
||||
@@ -226,6 +240,27 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
||||
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 {
|
||||
flex: 1 1 280px;
|
||||
max-width: 340px;
|
||||
@@ -361,7 +396,8 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.wing-seal {
|
||||
/* Scorpius, from catalog positions, showing through the 70% opaque wings. */
|
||||
.wing-stars {
|
||||
position: absolute;
|
||||
inset: -70px 0;
|
||||
z-index: -1;
|
||||
@@ -369,9 +405,14 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.12;
|
||||
opacity: 0.55;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wing-stars img {
|
||||
width: 1100px;
|
||||
max-width: none;
|
||||
height: 400px;
|
||||
}
|
||||
.wing-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -17,6 +17,7 @@ import NowCard from "../components/home/NowCard.astro";
|
||||
import Portrait from "../components/home/Portrait.astro";
|
||||
import { currentRole, viewStatus, worldPhoto } from "../lib/home.ts";
|
||||
import { site } from "../lib/page.ts";
|
||||
import { mediaSrc } from "../lib/site.ts";
|
||||
|
||||
const s = await site();
|
||||
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>}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* constellation, then the list grouped Now / Up next / Finished, filtered by
|
||||
* medium, state, favorites and blog posts.
|
||||
*/
|
||||
import Divider from "@gk/ui/components/Divider.astro";
|
||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
import SearchField from "@gk/ui/components/SearchField.astro";
|
||||
import Constellation from "../../components/library/Constellation.astro";
|
||||
@@ -45,10 +44,10 @@ const typeHref = (key?: string) => {
|
||||
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.">
|
||||
<Fragment slot="hero"><LibraryHero current="library" lede={lede} /></Fragment>
|
||||
|
||||
<Fragment slot="hero">
|
||||
<LibraryHero current="library" lede={lede} />
|
||||
<Constellation items={constellation(items)} />
|
||||
<div class="divide"><Divider seed="library-list" /></div>
|
||||
</Fragment>
|
||||
|
||||
<FilterLayout action="/library" formId="lib-filters">
|
||||
<Fragment slot="rail">
|
||||
@@ -88,9 +87,6 @@ const FIRST = 12;
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.divide {
|
||||
padding: 24px 0 4px;
|
||||
}
|
||||
.types {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* counts, play times and box art sync from BoardGameGeek (a cover set in the
|
||||
* note wins); no filter for them yet.
|
||||
*/
|
||||
import Divider from "@gk/ui/components/Divider.astro";
|
||||
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
|
||||
import GiltCorners from "@gk/ui/components/GiltCorners.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 LibraryHero from "../../components/library/LibraryHero.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 { 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 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.
|
||||
const table: { label: string; game: Game; note?: string; live: boolean }[] = onTheTable(s).map(({ status, game }) => ({
|
||||
@@ -50,8 +58,8 @@ 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.">
|
||||
<Fragment slot="hero"><LibraryHero current="shelf" lede={lede} /></Fragment>
|
||||
|
||||
<Fragment slot="hero">
|
||||
<LibraryHero current="shelf" lede={lede} />
|
||||
<section aria-labelledby="sh-table" class="top">
|
||||
<div class="count gk-featured">
|
||||
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
|
||||
@@ -85,8 +93,7 @@ const sortHref = (key: string) => {
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divide"><Divider seed="shelf-list" /></div>
|
||||
</Fragment>
|
||||
|
||||
<FilterLayout action="/library/shelf" formId="shelf-filters">
|
||||
<Fragment slot="rail">
|
||||
@@ -99,11 +106,29 @@ const sortHref = (key: string) => {
|
||||
</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">
|
||||
<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="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>
|
||||
{(synced.players || synced.time) && <p class="rail-note">Player counts and play times come from BoardGameGeek.</p>}
|
||||
</Fragment>
|
||||
|
||||
<section aria-label="Games" class="games">
|
||||
@@ -115,8 +140,9 @@ const sortHref = (key: string) => {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</FilterLayout>
|
||||
@@ -252,8 +278,36 @@ const sortHref = (key: string) => {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.divide {
|
||||
padding: 24px 0 4px;
|
||||
.rail-note {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -322,7 +376,13 @@ const sortHref = (key: string) => {
|
||||
played: desc("lp"),
|
||||
az: (a, b) => title(a).localeCompare(title(b)),
|
||||
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 data = new FormData(form);
|
||||
@@ -331,16 +391,29 @@ const sortHref = (key: string) => {
|
||||
const owned = data.has("owned");
|
||||
const want = data.has("want");
|
||||
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;
|
||||
for (const c of cards) {
|
||||
for (const c of [...grid.children] as HTMLElement[]) {
|
||||
const d = c.dataset;
|
||||
const [lo, hi] = (d.pl ?? "").split("-").map(Number);
|
||||
const t = d.t ? Number(d.t) : NaN;
|
||||
const ok =
|
||||
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
|
||||
(!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))) &&
|
||||
(!q || text.get(c)!.includes(q));
|
||||
c.hidden = !ok;
|
||||
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 (noun) noun.textContent = n === 1 ? "game" : "games";
|
||||
@@ -350,8 +423,24 @@ const sortHref = (key: string) => {
|
||||
const sort = (key: string) => {
|
||||
sortInput.value = key;
|
||||
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("submit", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -38,7 +38,7 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
|
||||
<TickScale width={120} count={24} />
|
||||
</span>
|
||||
<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>
|
||||
{r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>}
|
||||
<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 { site } from "../../lib/page.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 slug = Astro.params.slug ?? "";
|
||||
@@ -40,7 +40,7 @@ const groups = optionGroups(line);
|
||||
const body = s.render(line.body);
|
||||
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">
|
||||
<Breadcrumb here={d.category} />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import Swatches from "../../components/store/Swatches.astro";
|
||||
import Terms from "../../components/store/Terms.astro";
|
||||
import { site } from "../../lib/page.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 st = storeState(s);
|
||||
@@ -38,7 +38,7 @@ const colorStrips = selling.flatMap((l) =>
|
||||
.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">
|
||||
<div class="hero">
|
||||
<div class="hero-main">
|
||||
|
||||
@@ -14,7 +14,7 @@ import LineStatus from "../../components/store/LineStatus.astro";
|
||||
import OrderForm from "../../components/store/OrderForm.astro";
|
||||
import { site } from "../../lib/page.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 { 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 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>}
|
||||
<section class="intro" aria-labelledby="order-h1">
|
||||
<Breadcrumb here="Order request" />
|
||||
|
||||
@@ -13,7 +13,7 @@ import TickScale from "@gk/ui/components/TickScale.astro";
|
||||
import Breadcrumb from "../../../components/store/Breadcrumb.astro";
|
||||
import { parseOrderSent } from "../../../lib/forms.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);
|
||||
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 replyTime = s?.settings()?.replyTime ?? "a few days";
|
||||
const line = sent?.lineSlug && s ? storeState(s).lines.find((l) => l.slug === sent.lineSlug) : undefined;
|
||||
const turnaround = line && s ? storeState(s).category(line.data.category).turnaround : undefined;
|
||||
const st = s ? storeState(s) : 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 = [
|
||||
...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
|
||||
...(sent?.rows ?? []),
|
||||
@@ -35,7 +36,7 @@ const steps = [
|
||||
{ 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" />
|
||||
<section class="intro" aria-labelledby="sent-h1">
|
||||
<Breadcrumb here="Request sent" />
|
||||
|
||||
@@ -269,7 +269,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
|
||||
|
||||
.cards {
|
||||
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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Button from "@gk/ui/components/Button.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 Seal from "@gk/ui/components/Seal.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 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}>
|
||||
{kindPills.length > 0 && <div slot="action" class="kinds" data-kinds hidden><FilterGroup label="Kind of make" items={kindPills} /></div>}
|
||||
{
|
||||
makes.length > 0 ? (
|
||||
<ul class="makes">
|
||||
@@ -105,7 +117,7 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
|
||||
const photos = makePhotos(m);
|
||||
const log = s.backlinks(m, { type: "post" })[0];
|
||||
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" />
|
||||
{photos.length > 1 && (
|
||||
<span class="m-more">
|
||||
@@ -170,6 +182,23 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
|
||||
</CtaBand>
|
||||
</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>
|
||||
.crumb {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitBody, renderBody } 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("tightens a resume that just spills onto a second page", async () => {
|
||||
const ten = { ...base, roles: Array.from({ length: 10 }, (_, i) => role(i)) };
|
||||
expect((await renderBody(ten)).pages).toBe(2);
|
||||
const fitted = await fitBody(ten);
|
||||
expect(fitted.pages).toBe(1);
|
||||
expect(fitted.end).toBeLessThanOrEqual(792 - 70);
|
||||
});
|
||||
|
||||
it("leaves a resume that runs well 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: "status-kinds", title: "Status kinds", publish: true, kinds: seed.statusKinds });
|
||||
for (const p of seed.pages) {
|
||||
const { body, source: _source, ...rest } = p;
|
||||
add({ type: "page", created: today, ...rest }, body);
|
||||
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,
|
||||
portrait: portrait ? attach(portrait, "Pages") : undefined,
|
||||
photos: photos?.map((ph) => ({ ...ph, file: attach(ph.file, "Pages") })),
|
||||
},
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Library: reading
|
||||
@@ -340,6 +349,7 @@ export function build(legacyDir: string): BuildResult {
|
||||
mode: "made to order",
|
||||
channel: "here",
|
||||
summary: p.summary,
|
||||
menuName: ({ cards: "Proxy cards", deckBoxes: "Deck boxes" } as Record<string, string>)[p.slotKey],
|
||||
icon: p.icon,
|
||||
order: i + 1,
|
||||
// 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",
|
||||
page: "/",
|
||||
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.",
|
||||
source: "legacy/src/pages/index.astro",
|
||||
},
|
||||
@@ -56,6 +57,11 @@ export const pages = [
|
||||
title: "About me",
|
||||
page: "/about",
|
||||
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: [
|
||||
{ icon: "map-marker", text: "Philadelphia, PA" },
|
||||
{ icon: "briefcase", text: "BTP, Cloud, and Mobile App Lead at Implico Inc." },
|
||||
@@ -272,7 +278,7 @@ export const skills = {
|
||||
*/
|
||||
export const experience: Record<string, Record<string, unknown>> = {
|
||||
implico: {
|
||||
role: "Team Lead, BTP Cloud & Mobile Apps and US Managed Services",
|
||||
role: "Team Lead, BTP Cloud & Mobile Apps and North America Managed Services",
|
||||
previousRoles: ["BTP Cloud and Mobile App Lead", "Lead Developer", "Software Developer"],
|
||||
location: "Remote",
|
||||
focus: "Leading the team behind Carrier Connect, an SAP logistics platform for third-party carriers.",
|
||||
@@ -280,6 +286,7 @@ export const experience: Record<string, Record<string, unknown>> = {
|
||||
"Architected Carrier Connect, an SDM for SAP platform that connects third-party carriers to a central purchase order system; it handles 2,000+ orders a day for 500+ active users at 50+ companies with 99.999% uptime.",
|
||||
"Save about 72 hours per delivery by tracking loading and delivery events and streamlining documents in one system.",
|
||||
"Built the core delivery interface, the file transfer interfaces and the inventory polling and delivery system, and designed the REST API, Excel and CSV, and Driver App integration paths that feed it.",
|
||||
"Since June 2026, manage, lead and develop Implico's managed services practice in North America.",
|
||||
"Lead a 4-person development team and 6 extended US Managed Services members, owning career paths, yearly reviews and resource planning.",
|
||||
"Run the customer side: sales demos, presentations, requirement gathering, training and ongoing communication. Also handle network engineering and administration.",
|
||||
"Established the team's engineering standards on Google's published best practices: code review, pull requests, testing and code coverage.",
|
||||
|
||||
@@ -48,9 +48,10 @@ describe("migration", () => {
|
||||
});
|
||||
|
||||
it("takes the resume from Greg's interview answers", () => {
|
||||
const implico = find("experience", "Team Lead, BTP Cloud & Mobile Apps and US Managed Services at Implico Inc.")!.data as any;
|
||||
const implico = find("experience", "Team Lead, BTP Cloud & Mobile Apps and North America Managed Services at Implico Inc.")!.data as any;
|
||||
expect(implico.bullets.join(" ")).toContain("500+ active users at 50+ companies");
|
||||
expect(implico.bullets.join(" ")).toContain("sales demos");
|
||||
expect(implico.bullets.join(" ")).toContain("managed services practice in North America");
|
||||
expect(result.notes.filter((n) => n.data.type === "experience" && (n.data as any).earlier)).toHaveLength(2);
|
||||
expect(find("page", "Resume")?.data).toMatchObject({ page: "/resume", stats: expect.any(Array), projects: expect.any(Array) });
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ publish: false # Only notes set to true appear on the site.
|
||||
created: "{{date:YYYY-MM-DD}}" # optional
|
||||
page: # The page address this text belongs to, like /about.
|
||||
lede: # optional. The one line under the page title.
|
||||
portrait: # optional. Greg's photo on the landing and About pages.
|
||||
---
|
||||
|
||||
<!-- 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
|
||||
channelUrl: # optional. For Etsy or Patiun.com lines.
|
||||
summary:
|
||||
menuName: # optional. A short name for the Store menu, like Proxy cards.
|
||||
icon: # optional
|
||||
order: # optional
|
||||
slots: # optional. Made to order lines only.
|
||||
|
||||