diff --git a/docs/site.md b/docs/site.md index ea109fc..c14e226 100644 --- a/docs/site.md +++ b/docs/site.md @@ -79,3 +79,29 @@ SITE_DIR=… DATA_DIR=… CONTENT_TOKENS="reader:<24+ chars>" npm run content # the site CONTENT_URL=http://localhost:4310 CONTENT_TOKEN= npm run site # http://localhost:4330 ``` + +For a local preview of drafts, set `CONTENT_INCLUDE_DRAFTS=true` with an editor +or admin token. Never set it where the public can reach the site. + +## The resume PDF + +`/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume` +(`src/lib/resume.ts`), so the two never disagree. It is one plain column so +applicant tracking systems read it in order, uses the brand TTFs in +`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change. +The highlights row comes from the `/resume` Page text note's `stats`. + +## Where each page lives + +| Page | File | Helpers | +| --- | --- | --- | +| Landing, About, Now | `pages/index.astro`, `about.astro`, `now.astro` | `lib/home.ts`, `components/home` | +| Resume and PDF | `pages/resume.astro`, `resume.pdf.ts` | `lib/resume.ts`, `lib/resume-pdf.ts` | +| Work, case studies | `pages/work/` | `lib/work.ts`, `components/work`, `components/blog/PostLayout` | +| Inquiries, sent, 404 | `pages/inquiries/`, `404.astro` | `lib/work.ts` | +| Library, Shelf | `pages/library/` | `lib/library.ts`, `components/library` | +| Workshop, crafts, Tabletop | `pages/workshop/`, `tabletop.astro` | `lib/world.ts`, `components/world` | +| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` | +| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` | + +Forms post to `/api/inquiries` and `/api/orders`, which arrive in phase 6. diff --git a/package-lock.json b/package-lock.json index 28b3690..a0f7d2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1741,6 +1741,30 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -2102,6 +2126,15 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -2182,6 +2215,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2625,6 +2668,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2668,6 +2731,15 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2810,6 +2882,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3082,6 +3163,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -3513,6 +3600,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3561,6 +3654,23 @@ "fontkitten": "^1.0.2" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/fontkitten": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", @@ -4231,6 +4341,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -4710,6 +4839,12 @@ "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "license": "MIT" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4745,6 +4880,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdfkit": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz", + "integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", + "fontkit": "^2.0.4", + "linebreak": "^1.1.0", + "png-js": "^2.0.0" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -4778,6 +4927,14 @@ "node": ">=16.20.0" } }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -4980,6 +5137,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retext-smartypants": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", @@ -5522,7 +5685,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/tsx": { @@ -5638,6 +5800,26 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6641,10 +6823,12 @@ "@gk/schema": "*", "@gk/ui": "*", "astro": "7.3.5", - "marked": "18.0.14" + "marked": "18.0.14", + "pdfkit": "^0.20.2" }, "devDependencies": { "@astrojs/check": "0.9.10", + "@types/pdfkit": "^0.17.6", "typescript": "^5.9.0" } }, diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index bcf7b6b..b4508aa 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -193,6 +193,10 @@ export const make = def({ date: isoDate({ title: "Finished" }).optional(), photos: z.array(image({ title: "Photo" })).meta({ title: "Photos" }).optional(), blurb: line({ title: "Blurb" }).optional(), + tags: z + .array(line({ title: "Tag" })) + .meta({ title: "Tags", description: "Like sword rack, which puts the make on the Workshop's sword rack." }) + .optional(), }), }); @@ -251,6 +255,7 @@ export const deck = def({ archidektId: z.number().int().meta({ title: "Archidekt deck id" }).optional(), listUrl: z.url().meta({ title: "Deck list link", description: "For games not on Archidekt." }).optional(), take: line({ title: "Your take", description: "One line on the deck." }).optional(), + favorite: z.boolean().meta({ title: "Favorite", description: "Shows first among the decks." }).optional(), }), }); @@ -265,7 +270,11 @@ export const experience = def({ body: "Optional longer notes; the resume uses the bullets.", schema: note("experience", { company: line({ title: "Company" }), - role: line({ title: "Role" }), + role: line({ title: "Role", description: "The current or last title at this company." }), + previousRoles: strings({ + title: "Earlier titles", + description: "Earlier titles at the same company, newest first, shown as Previously.", + }).optional(), start: isoMonth({ title: "Start" }), end: isoMonth({ title: "End", description: "Leave out while it is your current role." }).optional(), location: line({ title: "Location" }).optional(), @@ -286,7 +295,13 @@ export const skills = def({ core: strings({ title: "Core strengths" }), learning: strings({ title: "Learning now" }).optional(), stack: z - .array(z.object({ label: line({ title: "Group" }), items: strings({ title: "Items" }) })) + .array( + z.object({ + label: line({ title: "Group" }), + items: strings({ title: "Items" }), + core: strings({ title: "Core items", description: "Items from this group outlined as the core stack." }).optional(), + }), + ) .meta({ title: "Technical stack" }), }), }); @@ -476,6 +491,16 @@ export const page = def({ .array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) })) .meta({ title: "Quick facts" }) .optional(), + stats: z + .array( + z.object({ + value: line({ title: "Value", description: "Like 1,000+." }), + label: line({ title: "Label", description: "Like Active users." }), + detail: line({ title: "Detail" }).optional(), + }), + ) + .meta({ title: "Highlights", description: "Big numbers on the page, like the resume highlights. Only real, checkable numbers." }) + .optional(), }), }); diff --git a/services/site/astro.config.mjs b/services/site/astro.config.mjs index 5bf9924..7a616de 100644 --- a/services/site/astro.config.mjs +++ b/services/site/astro.config.mjs @@ -11,7 +11,8 @@ export default defineConfig({ devToolbar: { enabled: false }, server: { host: true, port: Number(process.env.PORT ?? 4330) }, vite: { - ssr: { noExternal: ["@gk/ui"] }, + // cookie is bundled so the server never picks up express's older copy hoisted to the root. + ssr: { noExternal: ["@gk/ui", "cookie"] }, // See packages/ui/astro.config.mjs: astro needs its own cookie, not express's. environments: { prerender: { resolve: { noExternal: ["cookie"] } } }, }, diff --git a/services/site/package.json b/services/site/package.json index 6902a48..af895a9 100644 --- a/services/site/package.json +++ b/services/site/package.json @@ -15,10 +15,12 @@ "@gk/schema": "*", "@gk/ui": "*", "astro": "7.3.5", - "marked": "18.0.14" + "marked": "18.0.14", + "pdfkit": "^0.20.2" }, "devDependencies": { "@astrojs/check": "0.9.10", + "@types/pdfkit": "^0.17.6", "typescript": "^5.9.0" } } diff --git a/services/site/src/assets/pdf-fonts/Cinzel-Bold.ttf b/services/site/src/assets/pdf-fonts/Cinzel-Bold.ttf new file mode 100644 index 0000000..c6b9f1f Binary files /dev/null and b/services/site/src/assets/pdf-fonts/Cinzel-Bold.ttf differ diff --git a/services/site/src/assets/pdf-fonts/Cinzel-SemiBold.ttf b/services/site/src/assets/pdf-fonts/Cinzel-SemiBold.ttf new file mode 100644 index 0000000..8f2022a Binary files /dev/null and b/services/site/src/assets/pdf-fonts/Cinzel-SemiBold.ttf differ diff --git a/services/site/src/assets/pdf-fonts/LICENSE.md b/services/site/src/assets/pdf-fonts/LICENSE.md new file mode 100644 index 0000000..adc5b00 --- /dev/null +++ b/services/site/src/assets/pdf-fonts/LICENSE.md @@ -0,0 +1,10 @@ +# Fonts + +Subset to Latin and served as WOFF2 from the static TTFs in the brand source +(`branding/source/patiun-brand-source.zip`, `fonts/static`). + +- **Cinzel**, copyright 2012 The Cinzel Project Authors (Natanael Gama). +- **Montserrat**, copyright 2011 The Montserrat Project Authors. + +Both are licensed under the SIL Open Font License, Version 1.1: +https://openfontlicense.org/open-font-license-official-text/ diff --git a/services/site/src/assets/pdf-fonts/Montserrat-Medium.ttf b/services/site/src/assets/pdf-fonts/Montserrat-Medium.ttf new file mode 100644 index 0000000..ee36415 Binary files /dev/null and b/services/site/src/assets/pdf-fonts/Montserrat-Medium.ttf differ diff --git a/services/site/src/assets/pdf-fonts/Montserrat-Regular.ttf b/services/site/src/assets/pdf-fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..23db4aa Binary files /dev/null and b/services/site/src/assets/pdf-fonts/Montserrat-Regular.ttf differ diff --git a/services/site/src/assets/pdf-fonts/Montserrat-SemiBold.ttf b/services/site/src/assets/pdf-fonts/Montserrat-SemiBold.ttf new file mode 100644 index 0000000..e6a912f Binary files /dev/null and b/services/site/src/assets/pdf-fonts/Montserrat-SemiBold.ttf differ diff --git a/services/site/src/components/blog/BlogLayout.astro b/services/site/src/components/blog/BlogLayout.astro new file mode 100644 index 0000000..805f73c --- /dev/null +++ b/services/site/src/components/blog/BlogLayout.astro @@ -0,0 +1,41 @@ +--- +/** The two-column body of the Blog and topic pages: the posts, then the sidebar. */ +interface Props { + aside: string; +} +const { aside } = Astro.props; +--- +
+
+ +
+ + diff --git a/services/site/src/components/blog/ConnectedPages.astro b/services/site/src/components/blog/ConnectedPages.astro new file mode 100644 index 0000000..5da8808 --- /dev/null +++ b/services/site/src/components/blog/ConnectedPages.astro @@ -0,0 +1,75 @@ +--- +/** The Connected pages panel on a post: see connectedPages() in lib/blog.ts. */ +import type { Connected } from "../../lib/blog.ts"; +import PostThumb from "./PostThumb.astro"; +import SidePanel from "./SidePanel.astro"; + +interface Props { + items: Connected[]; +} +const { items } = Astro.props; +--- +{ + items.length > 0 && ( + + + + ) +} + + diff --git a/services/site/src/components/blog/CrumbHero.astro b/services/site/src/components/blog/CrumbHero.astro new file mode 100644 index 0000000..8e2a9be --- /dev/null +++ b/services/site/src/components/blog/CrumbHero.astro @@ -0,0 +1,71 @@ +--- +/** + * A hero whose eyebrow is a breadcrumb ("Blog / Making"), for topic, post + * and case study pages. Extra content (the meta line) goes in the slot. + */ +interface Props { + crumbs: { label: string; href?: string }[]; + title: string; + lede?: string; + /** Long titles (posts) wrap at 22 characters and run slightly smaller. */ + long?: boolean; +} +const { crumbs, title, lede, long } = Astro.props; +--- +
+ +

{title}

+ {lede &&

{lede}

} + +
+ + diff --git a/services/site/src/components/blog/FactsStrip.astro b/services/site/src/components/blog/FactsStrip.astro new file mode 100644 index 0000000..a261dc1 --- /dev/null +++ b/services/site/src/components/blog/FactsStrip.astro @@ -0,0 +1,111 @@ +--- +/** A case study's facts: employer, role, period, key numbers and tech. */ +import Chip from "@gk/ui/components/Chip.astro"; +import Kicker from "@gk/ui/components/Kicker.astro"; +import type { Post } from "../../lib/blog.ts"; + +interface Props { + post: Post; +} +const d = Astro.props.post.data; +const cells = [ + { label: "Employer", value: d.employer ?? "Personal project" }, + d.role && { label: "Role", value: d.role }, + d.period && { label: "Period", value: d.period }, +].filter((c): c is { label: string; value: string } => !!c); +const facts = d.facts ?? []; +--- +
+
+ { + cells.map((c) => ( +
+
{c.label}
+
{c.value}
+
+ )) + } + { + facts.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ )) + } +
+ { + d.tech && d.tech.length > 0 && ( +
+ Tech +
    + {d.tech.map((t) => ( +
  • + {t} +
  • + ))} +
+
+ ) + } +
+ + diff --git a/services/site/src/components/blog/LeadPost.astro b/services/site/src/components/blog/LeadPost.astro new file mode 100644 index 0000000..17be9f1 --- /dev/null +++ b/services/site/src/components/blog/LeadPost.astro @@ -0,0 +1,232 @@ +--- +/** + * The Blog's top slot: the featured or latest post in the one featured + * panel. The cover fills the left side when the post has one; otherwise the + * blog's seal art stands in. + */ +import Button from "@gk/ui/components/Button.astro"; +import Chip from "@gk/ui/components/Chip.astro"; +import GiltCorners from "@gk/ui/components/GiltCorners.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StarPath from "@gk/ui/components/StarPath.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import { filamentBanner } from "@gk/ui/components/seals.ts"; +import { dateLine, nowStatuses, pageLinks, type Post } from "../../lib/blog.ts"; +import { mediaSrc, type SiteIndex } from "../../lib/site.ts"; + +interface Props { + post: Post; + label: "Featured" | "Latest"; + site: SiteIndex; +} +const { post, label, site: s } = Astro.props; +const href = s.href(post) ?? "#"; +const cover = mediaSrc(post.data.cover); +const links = pageLinks(s, post); +const now = nowStatuses(s, post); +--- +
+
+ { + cover ? ( + <> + {post.title} +
+
+ + + {label} · {post.data.topic} + {dateLine(post)} + {now.map((st) => Now · {st.data.kind})} + +

{post.title}

+ + {post.data.summary &&

{post.data.summary}

} + {links.length > 0 && {links.map((l) => {l.label})}} + +
+ +
+ + diff --git a/services/site/src/components/blog/PostLayout.astro b/services/site/src/components/blog/PostLayout.astro new file mode 100644 index 0000000..f0b15b1 --- /dev/null +++ b/services/site/src/components/blog/PostLayout.astro @@ -0,0 +1,462 @@ +--- +/** + * The all-dark reading layout shared by blog posts and case studies: hero + * with the meta line, the article panel (cover, body, tags), the sidebar (On + * this page, Connected pages), previous and next, related posts and a call + * to action. A case study passes its facts strip in the `facts` slot. + */ +import PageShell from "@gk/ui/components/PageShell.astro"; +import Button from "@gk/ui/components/Button.astro"; +import Chip from "@gk/ui/components/Chip.astro"; +import GiltCorners from "@gk/ui/components/GiltCorners.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import type { NavKey } from "@gk/ui/components/nav.ts"; +import type { PageFamily } from "@gk/ui/components/seals.ts"; +import { connectedPages, neighbours, nowStatuses, pageLinks, readMinutes, relatedPosts, withHeadings, type Post } from "../../lib/blog.ts"; +import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts"; +import ConnectedPages from "./ConnectedPages.astro"; +import CrumbHero from "./CrumbHero.astro"; +import PostList from "./PostList.astro"; +import SidePanel from "./SidePanel.astro"; +import StarHeading from "./StarHeading.astro"; + +interface Props { + post: Post; + site: SiteIndex; + family: PageFamily; + current: NavKey; + crumbs: { label: string; href?: string }[]; + /** The newest-first list this post sits in, for previous and next. */ + list: Post[]; + /** The posts Related may draw from. */ + pool: Post[]; + /** Words for previous and next, like "post" or "case study". */ + noun?: string; +} +const { post, site: s, family, current, crumbs, list, pool, noun = "post" } = Astro.props; +const d = post.data; +const body = post.body ?? ""; +const { html, headings } = withHeadings(s.render(body)); +const cover = mediaSrc(d.cover); +const links = pageLinks(s, post); +const now = nowStatuses(s, post); +const { previous, next } = neighbours(list, post); +let related = relatedPosts(s, post, pool); +let relatedText = "Posts that share pages with this one."; +if (!related.length) { + related = pool.filter((p) => p.path !== post.path && p.data.topic === d.topic).slice(0, 3); + relatedText = `More from ${d.topic}, newest first.`; +} +const connected = connectedPages(s, post); +const isCase = d.postType === "case study"; +const making = d.topic === "Making"; +--- + + + + + {d.summary && } + {cover && } + + + + +
+ + + {readMinutes(body)} min read + {(links.length > 0 || now.length > 0) && } + {now.map((st) => Now · {st.data.kind})} + {links.map((l) => {l.label})} +
+
+
+ + {Astro.slots.has("facts") &&
} + +
+
+ + { + cover && ( +
+ {post.title} +
+ ) + } + {html ?
:

[PLACEHOLDER]

} + { + d.tags && d.tags.length > 0 && ( +
+ Tagged + {d.tags.map((t) => {t})} +
+ ) + } +
+ {(headings.length > 1 || connected.length > 0) && ( + + )} +
+ + { + (previous || next) && ( + + ) + } + + { + related.length > 0 && ( +
+
+ Keep reading +

{relatedText}

+
+ +
+ ) + } + +
+
+
+

{isCase ? "Have a project like this?" : "Want something like this?"}

+

+ {isCase ? "Tell me about it in an inquiry, or see the rest of my work." : making ? "Some of it is in the Store. Anything else can start as an inquiry." : "Anything you want to ask or start can begin as an inquiry."} +

+
+
+ {isCase ? ( + <> + + + + ) : making ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ + diff --git a/services/site/src/components/blog/PostList.astro b/services/site/src/components/blog/PostList.astro new file mode 100644 index 0000000..25f209b --- /dev/null +++ b/services/site/src/components/blog/PostList.astro @@ -0,0 +1,91 @@ +--- +/** + * Posts as fixed-height rows, or an empty state when there are none. + */ +import Button from "@gk/ui/components/Button.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import type { Post } from "../../lib/blog.ts"; +import type { SiteIndex } from "../../lib/site.ts"; +import PostRow from "./PostRow.astro"; + +interface Props { + posts: Post[]; + site: SiteIndex; + /** Kicker per post, e.g. the topic and its linked craft. */ + kicker?: (p: Post) => string | undefined; + /** Empty state text. */ + emptyTitle?: string; + emptyText?: string; + emptyAction?: { label: string; href: string }; + as?: "h2" | "h3"; +} +const { posts, site: s, kicker, emptyTitle = "Nothing written here yet", emptyText, emptyAction, as } = Astro.props; +--- +{ + posts.length ? ( + + ) : ( +
+ + + +
+

{emptyTitle}

+ {emptyText &&

{emptyText}

} + {emptyAction && ( + + )} +
+
+ ) +} + + diff --git a/services/site/src/components/blog/PostRow.astro b/services/site/src/components/blog/PostRow.astro new file mode 100644 index 0000000..1946e93 --- /dev/null +++ b/services/site/src/components/blog/PostRow.astro @@ -0,0 +1,139 @@ +--- +/** + * One post as a fixed-height row: picture, topic and date, title, a + * two-line summary, Now chips for linked notes and page-link chips. + */ +import Chip from "@gk/ui/components/Chip.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import { dateLine, nowStatuses, pageLinks, type Post } from "../../lib/blog.ts"; +import { mediaSrc, type SiteIndex } from "../../lib/site.ts"; +import PostThumb from "./PostThumb.astro"; + +interface Props { + post: Post; + site: SiteIndex; + /** The kicker over the title; the topic by default. */ + kicker?: string; + /** Heading level of the title. */ + as?: "h2" | "h3"; +} +const { post, site: s, kicker, as: H = "h3" } = Astro.props; +const href = s.href(post) ?? "#"; +const links = pageLinks(s, post); +const now = nowStatuses(s, post); +--- +
  • + + +
    + + {kicker ?? post.data.topic} + {dateLine(post)} + {now.map((st) => Now · {st.data.kind})} + + {post.title} + {post.data.summary &&

    {post.data.summary}

    } + + {links.length ? links.map((l) => {l.label}) : Not tied to a page} + +
    +
  • + + diff --git a/services/site/src/components/blog/PostThumb.astro b/services/site/src/components/blog/PostThumb.astro new file mode 100644 index 0000000..1c9b36f --- /dev/null +++ b/services/site/src/components/blog/PostThumb.astro @@ -0,0 +1,53 @@ +--- +/** + * A post's picture in a row or panel: the cover over star-chart art with the + * monogram, which also stands in when there is no cover or it fails to load. + */ +import monogram from "@gk/ui/assets/brand/gk-monogram.svg?url"; + +interface Props { + src?: string; + alt?: string; + width?: number; + height?: number; + class?: string; +} +const { src, alt = "", width = 176, height = 120, class: className } = Astro.props; +const mark = Math.round(Math.min(width, height) / 2); +--- + + + {src && {alt}} + + + diff --git a/services/site/src/components/blog/RightNowPanel.astro b/services/site/src/components/blog/RightNowPanel.astro new file mode 100644 index 0000000..444d68d --- /dev/null +++ b/services/site/src/components/blog/RightNowPanel.astro @@ -0,0 +1,83 @@ +--- +/** + * The newest statuses, each with its subject and how many posts link to it. + */ +import Button from "@gk/ui/components/Button.astro"; +import { plural } from "../../lib/page.ts"; +import type { SiteIndex } from "../../lib/site.ts"; +import SidePanel from "./SidePanel.astro"; + +interface Props { + site: SiteIndex; + limit?: number; +} +const { site: s, limit = 4 } = Astro.props; +const items = s.statuses().slice(0, limit).map((st) => { + const subject = st.data.subject ? s.byName(st.data.subject) : undefined; + const posts = subject ? s.backlinks(subject, { type: "post" }).filter((p) => (p as { data: { postType?: string } }).data.postType !== "case study").length : 0; + return { + kind: st.data.kind, + title: subject?.title ?? st.title, + href: (subject && s.href(subject)) ?? s.href(st) ?? "/now", + sub: posts ? plural(posts, "post") : st.data.note, + }; +}); +--- +{ + items.length > 0 && ( + + + + + ) +} + + diff --git a/services/site/src/components/blog/RowList.astro b/services/site/src/components/blog/RowList.astro new file mode 100644 index 0000000..ec1fc26 --- /dev/null +++ b/services/site/src/components/blog/RowList.astro @@ -0,0 +1,49 @@ +--- +/** + * Plain rows in a sidebar panel: a label and a count, with the current one + * in teal. Used for Topics and the Archive. + */ +interface Props { + items: { label: string; href: string; count?: number | string; current?: boolean }[]; +} +const { items } = Astro.props; +--- + + + diff --git a/services/site/src/components/blog/SidePanel.astro b/services/site/src/components/blog/SidePanel.astro new file mode 100644 index 0000000..659b17e --- /dev/null +++ b/services/site/src/components/blog/SidePanel.astro @@ -0,0 +1,47 @@ +--- +/** + * A sidebar panel on the blog pages: Midnight box, a faint seal in the + * corner, a starred kicker over a tick scale, then its rows. + */ +import Kicker from "@gk/ui/components/Kicker.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import type { SealName } from "@gk/ui/components/seals.ts"; + +interface Props { + title: string; + seal?: SealName; + /** Heading level for the title, so the outline stays correct. */ + as?: "h2" | "h3"; +} +const { title, seal, as: H = "h2" } = Astro.props; +--- +
    + {seal && } + {title} + +
    + + diff --git a/services/site/src/components/blog/StarHeading.astro b/services/site/src/components/blog/StarHeading.astro new file mode 100644 index 0000000..b8539db --- /dev/null +++ b/services/site/src/components/blog/StarHeading.astro @@ -0,0 +1,41 @@ +--- +/** A section heading led by a gold star, with the filament underline. */ +import Star from "@gk/ui/components/Star.astro"; + +interface Props { + id?: string; + level?: 2 | 3; +} +const { id, level = 2 } = Astro.props; +const H = `h${level}` as "h2" | "h3"; +--- +
    + + +
    + + diff --git a/services/site/src/components/blog/TopicsPanel.astro b/services/site/src/components/blog/TopicsPanel.astro new file mode 100644 index 0000000..13d9a21 --- /dev/null +++ b/services/site/src/components/blog/TopicsPanel.astro @@ -0,0 +1,18 @@ +--- +/** The Topics panel: every topic with its post count, linking to its page. */ +import { topicHref, topics } from "../../lib/blog.ts"; +import type { SiteIndex } from "../../lib/site.ts"; +import RowList from "./RowList.astro"; +import SidePanel from "./SidePanel.astro"; + +interface Props { + site: SiteIndex; + title?: string; + current?: string; +} +const { site: s, title = "Topics", current } = Astro.props; +const items = topics.map((t) => ({ label: t, href: topicHref(t), count: s.posts({ topic: t }).length, current: t === current })); +--- + + + diff --git a/services/site/src/components/home/BlogStrip.astro b/services/site/src/components/home/BlogStrip.astro new file mode 100644 index 0000000..27f0cc5 --- /dev/null +++ b/services/site/src/components/home/BlogStrip.astro @@ -0,0 +1,209 @@ +--- +/** + * The landing's "From the blog": one featured post (pinned, else newest) with + * its image and topic, and the two most recent beside it. Renders nothing when + * there are no posts. + */ +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import { slugify } from "@gk/schema"; +import { blogStrip, readMinutes } from "../../lib/home.ts"; +import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts"; + +interface Props { + site: SiteIndex; +} +const { site: s } = Astro.props; +const { featured, recent } = blogStrip(s); +const topicHref = (topic: string) => `/blog/topic/${slugify(topic)}`; +--- +{ + featured && ( +
    +
    +
    +

    From the blog

    +
    + All articles +
    +
    + + { + recent.length > 0 && ( +
    + {recent.map((p) => ( + + ))} +
    + ) + } +
    +
    + ) +} + + diff --git a/services/site/src/components/home/DoorLink.astro b/services/site/src/components/home/DoorLink.astro new file mode 100644 index 0000000..334d25c --- /dev/null +++ b/services/site/src/components/home/DoorLink.astro @@ -0,0 +1,67 @@ +--- +/** A row link inside a landing or About door: a title, an optional line, and an arrow. */ +import Icon from "@gk/ui/components/Icon.astro"; + +interface Props { + href: string; + title: string; + line?: string; + size?: "large" | "small"; +} +const { href, title, line, size = "large" } = Astro.props; +--- +
  • + + + {title} + {line && {line}} + + + +
  • + + diff --git a/services/site/src/components/home/NowCard.astro b/services/site/src/components/home/NowCard.astro new file mode 100644 index 0000000..7af6874 --- /dev/null +++ b/services/site/src/components/home/NowCard.astro @@ -0,0 +1,87 @@ +--- +/** + * One status on the landing's Now strip: kind, subject, a meta line, the + * note and when it was updated. The card links to the status on /now. + */ +import Star from "@gk/ui/components/Star.astro"; +import { formatDate } from "../../lib/site.ts"; +import type { StatusView } from "../../lib/home.ts"; + +interface Props { + view: StatusView; +} +const { view: v } = Astro.props; +--- +
  • + + {v.kind} + {v.pinned && Pinned} + +

    + {v.href ? {v.title} : v.title} +

    + {v.meta && {v.meta}} + {v.note &&

    {v.note}

    } + Updated +
  • + + diff --git a/services/site/src/components/home/Portrait.astro b/services/site/src/components/home/Portrait.astro new file mode 100644 index 0000000..87d94ae --- /dev/null +++ b/services/site/src/components/home/Portrait.astro @@ -0,0 +1,53 @@ +--- +/** + * 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. + */ +import GiltCorners from "@gk/ui/components/GiltCorners.astro"; +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import type { SealName } from "@gk/ui/components/seals.ts"; + +interface Props { + /** "hero": the thick gold frame on the landing; "panel": the framed panel on About. */ + variant?: "hero" | "panel"; + src?: string; + alt?: string; + seal?: SealName; + class?: string; +} +const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", class: className } = Astro.props; +--- +
    + + {variant === "panel" && } + {!src &&
    Portrait coming soon
    } +
    + + diff --git a/services/site/src/components/home/StatusRow.astro b/services/site/src/components/home/StatusRow.astro new file mode 100644 index 0000000..f377346 --- /dev/null +++ b/services/site/src/components/home/StatusRow.astro @@ -0,0 +1,181 @@ +--- +/** + * One status on the Now page, an anchor at its slug: a thumbnail, the kind + * (and Pinned), the subject linked to its page, the note, a chip to the page + * the kind belongs to, a post about the subject when there is one, and the date. + */ +import monogram from "@gk/ui/assets/brand/gk-monogram.svg?url"; +import Chip from "@gk/ui/components/Chip.astro"; +import Icon from "@gk/ui/components/Icon.astro"; +import Star from "@gk/ui/components/Star.astro"; +import type { StatusView } from "../../lib/home.ts"; +import { formatDate, type SiteIndex } from "../../lib/site.ts"; + +interface Props { + view: StatusView; + site: SiteIndex; +} +const { view: v, site: s } = Astro.props; +const post = v.subject ? s.backlinks(v.subject, { type: "post" })[0] : undefined; +const postHref = post ? s.href(post) : null; +--- +
  • + { + v.cover ? ( + + ) : ( + + ) + } +
    + + {v.kind} + {v.pinned && Pinned} + +

    {v.href ? {v.title} : v.title}

    + {v.meta && {v.meta}} + {v.note &&

    {v.note}

    } +
    +
    + {v.chip && {v.chip.label}} + { + post && postHref && ( + + + {post.title} + + ) + } + Updated +
    +
  • + + diff --git a/services/site/src/components/library/Constellation.astro b/services/site/src/components/library/Constellation.astro new file mode 100644 index 0000000..2e16eaa --- /dev/null +++ b/services/site/src/components/library/Constellation.astro @@ -0,0 +1,145 @@ +--- +/** + * The favorites constellation: round covers strung on a dotted gold line, + * each linking to its row in the list. + */ +import Star from "@gk/ui/components/Star.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import { mediaSrc } from "../../lib/site.ts"; +import type { Item } from "../../lib/library.ts"; + +interface Props { + items: Item[]; +} +const { items } = Astro.props; +const lift = [18, 0, 26, 0, 22, 2, 20]; +const n = items.length; +const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lift[i % lift.length]! + 40}`).join(" "); +--- +{ + n > 0 && ( +
    +
    +

    + + The favorites constellation +

    + +
    +
    + + +
    +
    + ) +} + + diff --git a/services/site/src/components/library/FilterLayout.astro b/services/site/src/components/library/FilterLayout.astro new file mode 100644 index 0000000..1bc594d --- /dev/null +++ b/services/site/src/components/library/FilterLayout.astro @@ -0,0 +1,61 @@ +--- +/** + * The rail-and-list layout both tabs use. The rail is a GET form, so filters + * work without JS (an Apply button submits it); the page script filters live + * and hides the button. + */ +interface Props { + action: string; + /** For the page script to find the form. */ + formId: string; +} +const { action, formId } = Astro.props; +--- +
    + +
    + +
    +
    + + diff --git a/services/site/src/components/library/GameCard.astro b/services/site/src/components/library/GameCard.astro new file mode 100644 index 0000000..b5e2451 --- /dev/null +++ b/services/site/src/components/library/GameCard.astro @@ -0,0 +1,130 @@ +--- +/** + * One board game on The Shelf: box art (or star-chart art), how it plays, + * title, blurb, its states and a link to BoardGameGeek. The card is the + * game's anchor (`#slug`); the data-* attributes feed the filter and sort + * script. + */ +import Card from "@gk/ui/components/Card.astro"; +import Star from "@gk/ui/components/Star.astro"; +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 type { Game } from "../../lib/library.ts"; + +interface Props { + game: Game; + site: SiteIndex; + hidden?: boolean; +} +const { game, site, hidden } = Astro.props; +const d = game.data; +const plays = d.howItPlays ?? []; +const status = site.statusFor(game); +--- +
  • p.toLowerCase()).join("|") || undefined} + data-o={d.owned ? "1" : undefined} + data-w={d.wantToPlay ? "1" : undefined} + data-f={d.favorite ? "1" : undefined} + data-lp={d.lastPlayed} + data-c={d.created} + hidden={hidden} +> + + +
    + {plays.length > 0 && {plays.join(" · ")}} +

    + {game.title} + {d.favorite && <>Favorite} +

    +

    {d.blurb}

    + { + (status || d.wantToPlay || !d.owned || d.lastPlayed) && ( +
    + {status && } + {d.wantToPlay && Want to play} + {!d.owned && Not owned} + {d.lastPlayed && Last played {formatDate(d.lastPlayed)}} +
    + ) + } + + BoardGameGeek: {game.title} + +
    +
    +
  • + + diff --git a/services/site/src/components/library/LibraryHero.astro b/services/site/src/components/library/LibraryHero.astro new file mode 100644 index 0000000..2a31554 --- /dev/null +++ b/services/site/src/components/library/LibraryHero.astro @@ -0,0 +1,81 @@ +--- +/** + * The hero both Library tabs share: the title, the tab control linking The + * Library and The Shelf, and the page's lede. + */ +import PageHero from "../PageHero.astro"; + +interface Props { + current: "library" | "shelf"; + lede?: string; +} +const { current, lede } = Astro.props; +const tabs = [ + { key: "library", href: "/library", name: "The Library", blurb: "Books, video games, anime, TV, movies" }, + { key: "shelf", href: "/library/shelf", name: "The Shelf", blurb: "Board games" }, +]; +--- + + +{lede &&

    {lede}

    } + + diff --git a/services/site/src/components/library/LibraryRow.astro b/services/site/src/components/library/LibraryRow.astro new file mode 100644 index 0000000..19540b1 --- /dev/null +++ b/services/site/src/components/library/LibraryRow.astro @@ -0,0 +1,162 @@ +--- +/** + * One Library item as a fixed-height row: cover (or star-chart art), title, + * verdict and note, details, the newest blog post about it, and on the right + * its Now chip and medium. The row is the item's anchor (`#slug`); the data-* + * attributes feed the filter script. Styles are global (lib-* classes) so + * hundreds of rows carry no scoping attributes. + */ +import Star from "@gk/ui/components/Star.astro"; +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import StatusChip from "../StatusChip.astro"; +import { mediaSrc, type SiteIndex } from "../../lib/site.ts"; +import { mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts"; + +interface Props { + item: Item; + site: SiteIndex; + hidden?: boolean; +} +const { item, site, hidden } = Astro.props; +const d = item.data; +const status = site.statusFor(item); +const post = site.backlinks(item, { type: "post" })[0]; +const postHref = post && site.href(post); +const details = [d.creator, d.year, d.platform, d.progress, d.group].filter((x) => x !== undefined && x !== ""); +--- + + + diff --git a/services/site/src/components/library/ListHeading.astro b/services/site/src/components/library/ListHeading.astro new file mode 100644 index 0000000..f344aee --- /dev/null +++ b/services/site/src/components/library/ListHeading.astro @@ -0,0 +1,55 @@ +--- +/** A list group's heading: a state dot, the name in Cinzel, and the gold underline. */ +interface Props { + id: string; + dot: "live" | "open" | "done"; +} +const { id, dot } = Astro.props; +--- +
    +

    + +
    + + diff --git a/services/site/src/components/library/RailGroup.astro b/services/site/src/components/library/RailGroup.astro new file mode 100644 index 0000000..6a82d54 --- /dev/null +++ b/services/site/src/components/library/RailGroup.astro @@ -0,0 +1,64 @@ +--- +/** + * One group in a filter rail: a gold kicker legend over checkboxes, links or + * filter pills. Checkboxes go in as ``. + */ +interface Props { + legend: string; + /** Lay the contents out as a row of pills. */ + pills?: boolean; +} +const { legend, pills } = Astro.props; +--- +
    + {legend} +
    +
    + + diff --git a/services/site/src/components/store/Breadcrumb.astro b/services/site/src/components/store/Breadcrumb.astro new file mode 100644 index 0000000..ff4c2ec --- /dev/null +++ b/services/site/src/components/store/Breadcrumb.astro @@ -0,0 +1,27 @@ +--- +/** "STORE / TABLETOP": the gold trail above a store page's title. */ +interface Props { + here: string; +} +const { here } = Astro.props; +--- + + + diff --git a/services/site/src/components/store/LineCard.astro b/services/site/src/components/store/LineCard.astro new file mode 100644 index 0000000..ef1fc51 --- /dev/null +++ b/services/site/src/components/store/LineCard.astro @@ -0,0 +1,231 @@ +--- +/** + * A product line on the Store: its lead image (star chart fallback), a ringed + * medallion with its icon, title, summary, three specs, its status and the + * price it starts from. Lines sold elsewhere link out to their shop. + */ +import Button from "@gk/ui/components/Button.astro"; +import Icon from "@gk/ui/components/Icon.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import { mediaSrc } from "../../lib/site.ts"; +import { channelOf, hasCapacity, lineIcon, priceSummary, shopLink, statusText, type Line } from "../../lib/store.ts"; + +interface Props { + line: Line; + /** The category's orders are open. */ + open: boolean; +} +const { line, open } = Astro.props; +const d = line.data; +const img = d.images?.[0]; +const price = priceSummary(line); +const shop = shopLink(line); +const ch = channelOf(line); +const soon = ch === "coming soon"; +const status = statusText(line); +const live = ch === "here" && open && hasCapacity(line); +const headId = `line-${line.slug}`; +--- +
    + { + !soon && ( + + ) + } + +
    +
    + {status} +
    +
    +

    {line.title}

    + +
    +

    {d.summary}

    + { + !!d.specs?.length && ( +
      + {d.specs.slice(0, 3).map((sp) => ( +
    • + + + {sp.label}: {sp.value} + +
    • + ))} +
    + ) + } + {(price || shop || !soon) && ( +
    + { + price ? ( + + From + {price.from} + {[price.note, ...price.more].filter(Boolean).join(" · ")} + + ) : ( + + ) + } + { + shop ? ( + + ) : ( + !soon && ( + + ) + ) + } +
    + )} +
    +
    + + diff --git a/services/site/src/components/store/LineStatus.astro b/services/site/src/components/store/LineStatus.astro new file mode 100644 index 0000000..7d2b512 --- /dev/null +++ b/services/site/src/components/store/LineStatus.astro @@ -0,0 +1,47 @@ +--- +/** + * A line's status panel: the SlotMeter for a made to order line sold here, + * its stock for an in stock line, or where it is sold. + */ +import SlotMeter from "@gk/ui/components/SlotMeter.astro"; +import { channelOf, statusText, type Line } from "../../lib/store.ts"; + +interface Props { + line: Line; + label?: string; + class?: string; +} +const { line, label = line.title, class: className } = Astro.props; +const ch = channelOf(line); +const slots = ch === "here" && line.data.mode === "made to order" ? line.data.slots : undefined; +--- +{ + slots ? ( + + ) : ( +
    + {label} + {statusText(line)} +
    + ) +} + + diff --git a/services/site/src/components/store/OrderForm.astro b/services/site/src/components/store/OrderForm.astro new file mode 100644 index 0000000..94f13f4 --- /dev/null +++ b/services/site/src/components/store/OrderForm.astro @@ -0,0 +1,288 @@ +--- +/** + * The order request form. Posts to /api/orders. Field names: + * name, email, line (a line slug), option.. (an option + * name), quantity, details, notes, terms ("accepted"). + * Without JS every line's options show, labelled with their line; a small + * script shows only the chosen line's options and terms. + */ +import Button from "@gk/ui/components/Button.astro"; +import Field from "@gk/ui/components/Field.astro"; +import Star from "@gk/ui/components/Star.astro"; +import { optionGroups, type Line, type Term } from "../../lib/store.ts"; +import Terms from "./Terms.astro"; + +interface Props { + lines: Line[]; + selected?: string; + storeTerms: Term[]; +} +const { lines, selected, storeTerms } = Astro.props; +const chosen = lines.find((l) => l.slug === selected)?.slug ?? (lines.length === 1 ? lines[0]!.slug : undefined); +const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +--- +
    +

    Order request form

    +
    + + +
    + +
    + What would you like? +
    + { + lines.map((l) => ( + + )) + } +
    +
    + + { + lines.flatMap((l) => + optionGroups(l).map(([group, opts]) => ( + + )), + ) + } + + + + + + { + (lines.some((l) => l.data.terms?.length) || storeTerms.length > 0) && ( +
    + Order terms + {lines.map((l) => + l.data.terms?.length ? ( + + ) : null, + )} + {storeTerms.length > 0 && ( +
    + {lines.length > 1 &&

    Every order

    } + +
    + )} +
    + ) + } + + + +
    + +
    + + + + + diff --git a/services/site/src/components/store/Swatches.astro b/services/site/src/components/store/Swatches.astro new file mode 100644 index 0000000..1c14798 --- /dev/null +++ b/services/site/src/components/store/Swatches.astro @@ -0,0 +1,94 @@ +--- +/** + * Color options as round swatches. With `names`, each swatch is listed with + * its name and stock; without, only the swatches show (names on hover and for + * screen readers). + */ +import type { Line } from "../../lib/store.ts"; + +type Option = NonNullable[number]; +interface Props { + options: Option[]; + names?: boolean; + class?: string; +} +const { options, names, class: className } = Astro.props; +--- +{ + names ? ( +
      + {options.map((o) => ( +
    • + {o.hex ?
    • + ))} +
    + ) : ( +
      + {options.map((o) => ( +
    • +
    • + ))} +
    + ) +} + + diff --git a/services/site/src/components/store/Terms.astro b/services/site/src/components/store/Terms.astro new file mode 100644 index 0000000..ca15c42 --- /dev/null +++ b/services/site/src/components/store/Terms.astro @@ -0,0 +1,49 @@ +--- +/** Order terms as ledger notes, each with its kind. */ +import Notice from "@gk/ui/components/Notice.astro"; +import type { Term } from "../../lib/store.ts"; + +interface Props { + terms: Term[]; +} +const { terms } = Astro.props; +const kindLabel = { info: "Good to know", warning: "Please note", rule: "Rule" } as const; +--- +
      + { + terms.map((t) => ( +
    • + + {kindLabel[t.kind]}: + {t.body} + +
    • + )) + } +
    + + diff --git a/services/site/src/components/work/ChoicePills.astro b/services/site/src/components/work/ChoicePills.astro new file mode 100644 index 0000000..783c146 --- /dev/null +++ b/services/site/src/components/work/ChoicePills.astro @@ -0,0 +1,75 @@ +--- +/** + * A single-choice question drawn as filter pills: real radio buttons inside + * gk-filter labels, so it works without a script and posts with the form. + */ +import Star from "@gk/ui/components/Star.astro"; + +interface Props { + legend: string; + name: string; + options: string[]; + /** The option checked at first. */ + checked?: string; +} +const { legend, name, options, checked } = Astro.props; +--- +
    + {legend} +
    + { + options.map((o) => ( + + )) + } +
    +
    + + diff --git a/services/site/src/components/work/FeaturedStudy.astro b/services/site/src/components/work/FeaturedStudy.astro new file mode 100644 index 0000000..ff632f5 --- /dev/null +++ b/services/site/src/components/work/FeaturedStudy.astro @@ -0,0 +1,209 @@ +--- +/** + * The one gilt panel on Work: the featured case study with its role, summary, + * tech, key facts and a link to the full study. + */ +import Button from "@gk/ui/components/Button.astro"; +import Chip from "@gk/ui/components/Chip.astro"; +import GiltCorners from "@gk/ui/components/GiltCorners.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import type { Note } from "../../lib/content.ts"; +import { workGroup, workKicker } from "../../lib/work.ts"; + +interface Props { + study: Note<"post">; + href: string | null; + core: Set; +} +const { study, href, core } = Astro.props; +const d = study.data; +const facts = d.facts ?? []; +--- + + + diff --git a/services/site/src/components/work/StudyCard.astro b/services/site/src/components/work/StudyCard.astro new file mode 100644 index 0000000..79936af --- /dev/null +++ b/services/site/src/components/work/StudyCard.astro @@ -0,0 +1,93 @@ +--- +/** A case study in the Work grid: kicker, title, role, summary, tech and a link. */ +import Chip from "@gk/ui/components/Chip.astro"; +import type { Note } from "../../lib/content.ts"; +import { workGroup, workKicker } from "../../lib/work.ts"; + +interface Props { + study: Note<"post">; + href: string | null; + core: Set; +} +const { study, href, core } = Astro.props; +const d = study.data; +const Tag = href ? "a" : "article"; +--- +
  • + + {workKicker(study)} +

    {d.title}

    + {d.role && {d.role}} + {d.summary && {d.summary}} + { + d.tech?.length ? ( + + {d.tech.slice(0, 5).map((t) => ( + {t} + ))} + {d.tech.length > 5 && +{d.tech.length - 5}} + + ) : null + } + { + href && ( + + Read the case study + + + ) + } +
    +
  • + + diff --git a/services/site/src/components/world/CtaBand.astro b/services/site/src/components/world/CtaBand.astro new file mode 100644 index 0000000..10b45d4 --- /dev/null +++ b/services/site/src/components/world/CtaBand.astro @@ -0,0 +1,57 @@ +--- +/** The closing band on a world page: a line, a note and one or two buttons. */ +interface Props { + title: string; + note?: string; +} +const { title, note } = Astro.props; +--- + + + diff --git a/services/site/src/components/world/NowStrip.astro b/services/site/src/components/world/NowStrip.astro new file mode 100644 index 0000000..df5b042 --- /dev/null +++ b/services/site/src/components/world/NowStrip.astro @@ -0,0 +1,129 @@ +--- +/** + * The "Right now" / "At the table now" strip: a gold kicker over a tick scale + * and a row of gold-framed cards, each with a thumbnail, a label, a name and + * a short note. + */ +import Kicker from "@gk/ui/components/Kicker.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import type { SealName } from "@gk/ui/components/seals.ts"; + +export interface NowItem { + id?: string; + label: string; + title: string; + note?: string; + href?: string | null; + img?: string; +} +interface Props { + id: string; + heading: string; + items: NowItem[]; + seal?: SealName; +} +const { id, heading, items, seal = "mini-astrolabe" } = Astro.props; +--- +
    +

    {heading}

    +
      + { + items.map((it) => { + const Tag = it.href ? "a" : "div"; + return ( +
    • + + {it.img ? ( + + ) : ( + + )} + + {it.label} + {it.title} + {it.note && {it.note}} + + +
    • + ); + }) + } +
    +
    + + diff --git a/services/site/src/components/world/WorldSection.astro b/services/site/src/components/world/WorldSection.astro new file mode 100644 index 0000000..b4a0958 --- /dev/null +++ b/services/site/src/components/world/WorldSection.astro @@ -0,0 +1,70 @@ +--- +/** + * A page section on the world pages: a gold star, the Cinzel heading with the + * filament underline, an optional intro line, and an action at the right. + */ +import Star from "@gk/ui/components/Star.astro"; + +interface Props { + id: string; + title: string; + intro?: string; + class?: string; +} +const { id, title, intro, class: className } = Astro.props; +--- +
    +
    +
    +

    {title}

    + + {intro &&

    {intro}

    } +
    + {Astro.slots.has("action") &&
    } +
    + +
    + + diff --git a/services/site/src/lib/blog.ts b/services/site/src/lib/blog.ts new file mode 100644 index 0000000..3a5cf62 --- /dev/null +++ b/services/site/src/lib/blog.ts @@ -0,0 +1,242 @@ +/** + * What the Blog, topic, post and case study pages ask beyond the SiteIndex: + * topics, reading time, page-link chips, Now chips, related posts, the + * archive and the post's table of contents. + */ +import { slugify, topics } from "@gk/schema"; +import type { Note } from "./content.ts"; +import { formatDate, mediaSrc, type SiteIndex } from "./site.ts"; + +export type Post = Note<"post">; +export type Topic = (typeof topics)[number]; + +export { topics }; + +/** The address of a topic page. */ +export const topicHref = (topic: string) => `/blog/topic/${slugify(topic)}`; + +/** The topic a URL segment names, or undefined. */ +export const topicFromSlug = (slug: string | undefined): Topic | undefined => topics.find((t) => slugify(t) === slug); + +/** Where each topic lives on the site, for the topic page's sidebar. */ +export const topicHome: Record = { + Making: { label: "Workshop", href: "/workshop", blurb: "Crafts, tools and makes" }, + Tabletop: { label: "Tabletop", href: "/tabletop", blurb: "Campaigns, systems, decks and card games" }, + Library: { label: "Library", href: "/library", blurb: "Books, shows, anime and games" }, + "Work and tech": { label: "Work", href: "/work", blurb: "Case studies and what I build" }, + Life: { label: "Now", href: "/now", blurb: "Everything I am into right now" }, +}; + +/** Minutes to read the body, at 220 words a minute. */ +export function readMinutes(body: string | undefined): number { + const words = (body ?? "") + .replace(//g, " ") + .split(/\s+/) + .filter(Boolean).length; + return Math.max(1, Math.round(words / 220)); +} + +/** "Oct 11, 2026 · 4 min read". */ +export const dateLine = (p: Post) => `${formatDate(p.data.date)} · ${readMinutes(p.body)} min read`; + +/** The top slot: the newest pinned post (Featured), else the newest post (Latest). */ +export function leadPost(posts: Post[]): { post: Post; label: "Featured" | "Latest" } | undefined { + const pinned = posts.find((p) => p.data.pinned); + if (pinned) return { post: pinned, label: "Featured" }; + return posts[0] ? { post: posts[0], label: "Latest" } : undefined; +} + +/** The section a note lives in, for chip labels like "Workshop · Leather". */ +const SECTION: Record = { + post: "Blog", + "library-item": "Library", + "board-game": "Shelf", + craft: "Workshop", + make: "Workshop", + station: "Workshop", + tool: "Workshop", + "tabletop-system": "Tabletop", + campaign: "Tabletop", + "card-game": "Tabletop", + deck: "Tabletop", + experience: "Resume", + status: "Now", + "product-line": "Store", +}; + +export function sectionOf(n: Note): string | undefined { + if (n.type === "post") return (n as Post).data.postType === "case study" ? "Work" : "Blog"; + return SECTION[n.type]; +} + +/** A smaller label under the section, for the Connected pages panel. */ +const KIND: Record = { + "library-item": "Entry", + "board-game": "Board game", + craft: "Craft", + make: "Make", + station: "Station", + tool: "Tool", + "tabletop-system": "System", + campaign: "Campaign", + "card-game": "Card game", + deck: "Deck", + experience: "Role", + status: "Status", + "product-line": "Product line", +}; + +export function kindOf(n: Note): string | undefined { + if (n.type === "post") return (n as Post).data.postType === "case study" ? "Case study" : "Post"; + return KIND[n.type]; +} + +/** "Workshop · Leather", or just the title for a page or unknown type. */ +export function chipLabel(n: Note): string { + const section = sectionOf(n); + return section && section !== n.title ? `${section} · ${n.title}` : n.title; +} + +export interface PageLink { + note: Note; + href: string; + label: string; +} + +/** The published pages a post links to, as chips. Statuses are left to the Now chips. */ +export function pageLinks(s: SiteIndex, post: Post): PageLink[] { + return s + .linksFrom(post) + .filter((n) => n.type !== "status" && n.path !== post.path) + .map((n) => ({ note: n, href: s.href(n), label: chipLabel(n) })) + .filter((l): l is PageLink => !!l.href); +} + +/** The current statuses of the notes a post links to, one per kind and subject. */ +export function nowStatuses(s: SiteIndex, post: Post): Note<"status">[] { + const seen = new Set(); + const out: Note<"status">[] = []; + for (const n of s.linksFrom(post)) { + const st = s.statusFor(n); + if (st && !seen.has(st.path)) { + seen.add(st.path); + out.push(st); + } + } + return out; +} + +/** A note's first image: its cover, else its first photo or product image. */ +export function imageOf(n: Note): string | undefined { + const d = n.data as Record; + const photos = d.photos as string[] | undefined; + const images = d.images as { file: string }[] | undefined; + return mediaSrc((d.cover as string) ?? photos?.[0] ?? images?.[0]?.file); +} + +/** Case-insensitive match on title and summary. */ +export function searchPosts(posts: Post[], q: string): Post[] { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return posts; + return posts.filter((p) => { + const hay = `${p.title} ${p.data.summary ?? ""}`.toLowerCase(); + return words.every((w) => hay.includes(w)); + }); +} + +/** Posts by month, newest first: [{ month: "2026-10", label: "October 2026", count }]. */ +export function archive(posts: Post[]): { month: string; label: string; count: number }[] { + const map = new Map(); + for (const p of posts) { + const m = p.data.date.slice(0, 7); + map.set(m, (map.get(m) ?? 0) + 1); + } + return [...map.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map(([month, count]) => ({ month, label: formatDate(`${month}-01`, { month: "long", day: false }), count })); +} + +/** + * Posts related to this one: posts that link to it or that it links to count + * most, then posts sharing linked pages, newest first among equals. + */ +export function relatedPosts(s: SiteIndex, post: Post, pool: Post[], limit = 3): Post[] { + const mine = new Set(s.linksFrom(post).map((n) => n.path)); + const backlinks = new Set(s.backlinks(post, { type: "post" }).map((n) => n.path)); + const scored = pool + .filter((p) => p.path !== post.path) + .map((p) => { + let score = backlinks.has(p.path) || mine.has(p.path) ? 10 : 0; + for (const n of s.linksFrom(p)) if (mine.has(n.path)) score += 1; + return { p, score }; + }) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score || b.p.data.date.localeCompare(a.p.data.date)); + return scored.slice(0, limit).map((x) => x.p); +} + +/** The posts before and after this one in a newest-first list. */ +export function neighbours(list: Post[], post: Post): { previous?: Post; next?: Post } { + const i = list.findIndex((p) => p.path === post.path); + if (i < 0) return {}; + return { previous: list[i + 1], next: list[i - 1] }; +} + +/** + * Give each h2 in rendered HTML an id and return the list, for the + * "On this page" panel. + */ +export function withHeadings(html: string): { html: string; headings: { id: string; text: string }[] } { + const headings: { id: string; text: string }[] = []; + const used = new Set(); + const out = html.replace(/

    ([\s\S]*?)<\/h2>/g, (_, inner: string) => { + const text = inner.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/"/g, '"').replace(/&/g, "&").trim(); + let id = slugify(text) || "section"; + for (let n = 2; used.has(id); n++) id = `${slugify(text)}-${n}`; + used.add(id); + headings.push({ id, text }); + return `

    ${inner}

    `; + }); + return { html: out, headings }; +} + +/** Escape text for XML. */ +export const xml = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +export interface Connected { + href: string; + kicker: string; + title: string; + sub: string; + img?: string; +} + +/** + * The pages a post links to and the pages that link back to it, each with + * its section, picture and a line on how it connects. + */ +export function connectedPages(s: SiteIndex, post: Post): Connected[] { + const seen = new Map(); + for (const n of [...s.linksFrom(post), ...s.backlinks(post)]) { + if (n.path === post.path || n.type === "post" || n.type === "status" || !s.href(n)) continue; + seen.set(n.path, n); + } + return [...seen.values()].map((n) => { + const parts: string[] = []; + if (s.linksFrom(n).some((x) => x.path === post.path)) parts.push("Links back here"); + if (n.type === "craft") { + const makes = s.all("make").filter((m) => s.byName(m.data.craft)?.path === n.path).length; + if (makes) parts.push(`${makes} ${makes === 1 ? "make" : "makes"}`); + } + const posts = s.backlinks(n, { type: "post" }).length; + if (posts > 1) parts.push(`${posts} posts`); + return { + href: s.href(n)!, + kicker: [sectionOf(n), kindOf(n)].filter(Boolean).join(" · "), + title: n.title, + sub: parts.join(" · "), + img: imageOf(n), + }; + }); +} diff --git a/services/site/src/lib/content.ts b/services/site/src/lib/content.ts index b5d9e94..e4bfc0d 100644 --- a/services/site/src/lib/content.ts +++ b/services/site/src/lib/content.ts @@ -30,6 +30,8 @@ export interface Redirect { const env = (k: string, fallback?: string) => process.env[k] ?? import.meta.env[k] ?? fallback; const baseUrl = () => (env("CONTENT_URL", "http://localhost:4310") as string).replace(/\/$/, ""); const token = () => env("CONTENT_TOKEN", "") as string; +/** Preview only: include unpublished notes. Needs an editor or admin token; never set in production. */ +const includeDrafts = () => env("CONTENT_INCLUDE_DRAFTS", "") === "true"; const refreshMs = () => Number(env("CONTENT_REFRESH_SECONDS", "30")) * 1000; async function api(path: string): Promise { @@ -44,7 +46,8 @@ async function api(path: string): Promise { async function loadAll(): Promise { const notes: Note[] = []; for (let offset = 0; ; offset += 1000) { - const page = await api(`/api/notes?full=true&published=true&limit=1000&offset=${offset}`); + const published = includeDrafts() ? "" : "&published=true"; + const page = await api<(Note & { publish?: boolean })[]>(`/api/notes?full=true${published}&limit=1000&offset=${offset}`); notes.push(...page); if (page.length < 1000) break; } diff --git a/services/site/src/lib/home.ts b/services/site/src/lib/home.ts new file mode 100644 index 0000000..4a3cfea --- /dev/null +++ b/services/site/src/lib/home.ts @@ -0,0 +1,216 @@ +/** + * Helpers for the Landing, About and Now pages: how a status reads (its + * subject, a meta line, the page chip), how the Now page groups statuses, and + * small lookups for the landing doors. + */ +import { world } from "@gk/ui/components/nav.ts"; +import type { Note } from "./content.ts"; +import { brandIcon, mediaSrc, type SiteIndex } from "./site.ts"; + +const MEDIUM_LABEL: Record = { + book: "Books", + "light novel": "Light novels", + webnovel: "Webnovels", + manga: "Manga", + manhwa: "Manhwa", + "video game": "Video games", + anime: "Anime", + TV: "TV", + movie: "Movies", +}; + +const SUB_LABEL: Partial> = { + "tabletop-system": "Systems", + campaign: "Campaigns", + "card-game": "Card games", + deck: "Decks", + "board-game": "The Shelf", + station: "Stations", + tool: "Tools", +}; + +const PAGE_LABEL: Record = { + ...Object.fromEntries(world.map((w) => [w.href, w.label])), + "/library/shelf": "The Shelf", + "/blog": "Blog", + "/store": "Store", + "/work": "Work", + "/resume": "Resume", + "/about": "About me", +}; + +/** "Library" for "/library", else the last path part with a capital. */ +export function pageLabel(path: string): string { + const clean = path.split("#")[0]!.replace(/\/$/, "") || "/"; + if (PAGE_LABEL[clean]) return PAGE_LABEL[clean]!; + const last = clean.split("/").filter(Boolean).pop() ?? "Home"; + return last.charAt(0).toUpperCase() + last.slice(1).replace(/-/g, " "); +} + +export interface StatusView { + status: Note<"status">; + slug: string; + kind: string; + icon: string; + pinned: boolean; + title: string; + href: string | null; + subject?: Note; + /** A short line about the subject: creator, medium or platform. */ + meta?: string; + note?: string; + date: string; + cover?: string; + chip?: { label: string; href: string }; + upNext: boolean; +} + +/** Everything a page shows about one status. */ +export function viewStatus(s: SiteIndex, status: Note<"status">): StatusView { + const d = status.data; + const link = d.subject ? s.link(d.subject) : null; + const subject = link?.note; + const kind = s.kind(d.kind); + const sd = (subject?.data ?? {}) as Record; + + const metaParts: string[] = []; + if (subject?.type === "library-item") { + if (sd.creator) metaParts.push(String(sd.creator)); + else if (sd.medium) metaParts.push(capital(String(sd.medium))); + if (sd.platform) metaParts.push(String(sd.platform)); + } else if (subject?.type === "deck" && sd.format) { + metaParts.push(String(sd.format)); + } else if (subject?.type === "campaign" && sd.platform) { + metaParts.push(String(sd.platform)); + } + + let sub: string | undefined; + if (subject?.type === "library-item") sub = MEDIUM_LABEL[String(sd.medium)]; + else if (subject?.type === "make" || subject?.type === "craft") { + const craft = subject.type === "craft" ? subject : s.byName(String(sd.craft ?? "")); + sub = craft?.title; + } else if (subject) sub = SUB_LABEL[subject.type]; + + const page = kind.page; + const chip = page ? { label: sub ? `${pageLabel(page)} · ${sub}` : pageLabel(page), href: page } : undefined; + + return { + status, + slug: status.slug, + kind: d.kind, + icon: brandIcon(kind.icon), + pinned: !!d.pinned, + title: link?.title ?? status.title, + href: link?.href ?? null, + subject, + meta: metaParts.join(" · ") || undefined, + note: d.note, + date: d.date, + cover: mediaSrc((sd.cover as string | undefined) ?? (status.data as { cover?: string }).cover), + chip, + upNext: isUpNext(d.kind, subject), + }; +} + +function isUpNext(kind: string, subject?: Note): boolean { + if (/up next|want to|queued|next/i.test(kind)) return true; + const sd = (subject?.data ?? {}) as Record; + return subject?.type === "library-item" && sd.state === "up next"; +} + +const capital = (x: string) => x.charAt(0).toUpperCase() + x.slice(1); + +/** The Now page's groups, in page order. */ +export const NOW_GROUPS = [ + { key: "making", title: "Making" }, + { key: "table", title: "At the table" }, + { key: "playing", title: "Playing" }, + { key: "reading", title: "Reading and listening" }, + { key: "watching", title: "Watching" }, + { key: "other", title: "Elsewhere" }, + { key: "next", title: "Up next" }, +] as const; +export type NowGroupKey = (typeof NOW_GROUPS)[number]["key"]; + +const READ = new Set(["book", "light novel", "webnovel", "manga", "manhwa"]); +const WATCH = new Set(["anime", "TV", "movie"]); +const TABLE = new Set(["tabletop-system", "campaign", "card-game", "deck", "board-game"]); +const MAKE = new Set(["craft", "make", "station", "tool"]); + +/** Which Now group a status belongs to: its subject decides, else its kind. */ +export function nowGroup(v: StatusView, s: SiteIndex): NowGroupKey { + if (v.upNext) return "next"; + const t = v.subject?.type; + if (t && MAKE.has(t)) return "making"; + if (t && TABLE.has(t)) return "table"; + if (t === "library-item") { + const medium = String((v.subject!.data as Record).medium); + if (READ.has(medium)) return "reading"; + if (WATCH.has(medium)) return "watching"; + if (medium === "video game") return "playing"; + } + const k = v.kind.toLowerCase(); + if (/read|listen/.test(k)) return "reading"; + if (/watch|caught up/.test(k)) return "watching"; + if (/play/.test(k)) return "playing"; + const page = s.kind(v.kind).page ?? ""; + if (page.startsWith("/workshop")) return "making"; + if (page.startsWith("/tabletop")) return "table"; + return "other"; +} + +/** Every status grouped for the Now page, empty groups left out. */ +export function groupStatuses(s: SiteIndex): { key: NowGroupKey; title: string; items: StatusView[] }[] { + const views = s.statuses().map((st) => viewStatus(s, st)); + return NOW_GROUPS.map((g) => ({ ...g, items: views.filter((v) => nowGroup(v, s) === g.key) })).filter((g) => g.items.length); +} + +/** The newest status date, for "Last updated". */ +export function lastUpdated(s: SiteIndex): string | undefined { + return s + .statuses() + .map((x) => x.data.date) + .sort() + .pop(); +} + +/** The current role (an experience with no end), newest first. */ +export function currentRole(s: SiteIndex): Note<"experience"> | undefined { + return [...s.all("experience")].filter((e) => !e.data.end).sort((a, b) => b.data.start.localeCompare(a.data.start))[0]; +} + +/** A make photo for the My world door: a favorite first, then Galaxy dice, then any. */ +export function worldPhoto(s: SiteIndex): { src: string; alt: string } | undefined { + const makes = s.all("make").filter((m) => m.data.cover || m.data.photos?.length); + const pick = + makes.find((m) => m.data.favorite) ?? makes.find((m) => m.slug === "galaxy-dice") ?? makes[0]; + if (!pick) return undefined; + const src = mediaSrc(pick.data.cover ?? pick.data.photos?.[0]); + return src ? { src, alt: pick.data.blurb ?? pick.title } : undefined; +} + +/** Minutes to read a body at 220 words a minute, at least one. */ +export function readMinutes(body: string): number { + const words = body.trim().split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(words / 220)); +} + +/** The featured post (pinned, else newest) and the two most recent after it. */ +export function blogStrip(s: SiteIndex): { featured?: Note<"post">; recent: Note<"post">[] } { + const posts = s.posts(); + const featured = posts.find((p) => p.data.pinned) ?? posts[0]; + return { featured, recent: posts.filter((p) => p !== featured).slice(0, 2) }; +} + +/** Split a Page text body into its intro (before the first heading) and the sections after. */ +export function splitIntro(body: string): { intro: string; sections: { heading: string; body: string }[] } { + const parts = body.split(/^##\s+/m); + const intro = parts.shift()?.trim() ?? ""; + return { + intro, + sections: parts.map((p) => { + const [heading, ...rest] = p.split("\n"); + return { heading: heading!.trim(), body: rest.join("\n").trim() }; + }), + }; +} diff --git a/services/site/src/lib/library.ts b/services/site/src/lib/library.ts new file mode 100644 index 0000000..fd24c7f --- /dev/null +++ b/services/site/src/lib/library.ts @@ -0,0 +1,164 @@ +/** + * The Library and The Shelf: how items group, filter and sort. The pages + * filter on the server from the query string (so they work without JS) and a + * small script repeats the same rules live in the browser. + */ +import type { Note } from "./content.ts"; +import type { SiteIndex } from "./site.ts"; + +export type Item = Note<"library-item">; +export type Game = Note<"board-game">; + +// ---------------------------------------------------------------- Library + +/** The list's three groups. Dropped items are left out. */ +export const sections = [ + { key: "now", label: "Now", states: ["now"] }, + { key: "next", label: "Up next", states: ["up next"] }, + { key: "done", label: "Finished", states: ["finished", "caught up", "played"] }, +] as const; +export type SectionKey = (typeof sections)[number]["key"]; + +export const sectionOf = (item: Item): SectionKey | undefined => + sections.find((s) => (s.states as readonly string[]).includes(item.data.state))?.key; + +/** The Type filter: media gathered the way the rail lists them. */ +export const mediaGroups = [ + { key: "books", label: "Books", media: ["book", "light novel"] }, + { key: "serials", label: "Webnovels, manga and manhwa", media: ["webnovel", "manga", "manhwa"] }, + { key: "games", label: "Video games", media: ["video game"] }, + { key: "screen", label: "Anime and TV", media: ["anime", "TV"] }, + { key: "movies", label: "Movies", media: ["movie"] }, +] as const; +export type MediaKey = (typeof mediaGroups)[number]["key"]; + +export const mediaGroupOf = (item: Item): MediaKey | undefined => + mediaGroups.find((g) => (g.media as readonly string[]).includes(item.data.medium))?.key; + +/** The short medium label on the right of a row. */ +export const mediumLabel = (medium: string) => (medium === "video game" ? "Game" : medium === "TV" ? "TV" : medium); + +/** How Finished is ordered: favorites, then items with a verdict, then A to Z. */ +export function byStanding(a: Item, b: Item): number { + return ( + Number(!!b.data.favorite) - Number(!!a.data.favorite) || + Number(!!b.data.verdict) - Number(!!a.data.verdict) || + a.title.localeCompare(b.title) + ); +} + +export interface LibraryFilter { + type?: MediaKey; + q: string; + show: Set; + favorites: boolean; + blog: boolean; +} + +export function libraryFilter(params: URLSearchParams): LibraryFilter { + const type = mediaGroups.find((g) => g.key === params.get("type"))?.key; + // A submitted form sends `f`; before that every group shows. + const submitted = params.has("f"); + const show = new Set( + submitted ? (params.getAll("show").filter((v) => sections.some((s) => s.key === v)) as SectionKey[]) : sections.map((s) => s.key), + ); + return { + type, + q: (params.get("q") ?? "").trim().toLowerCase(), + show, + favorites: params.get("fav") === "1", + blog: params.get("blog") === "1", + }; +} + +/** Text the search matches against. Kept in step with the row's visible text. */ +export const searchText = (parts: (string | number | undefined)[]) => + parts + .filter((p) => p !== undefined && p !== "") + .join(" ") + .toLowerCase(); + +/** + * The favorites constellation: favorites with a cover, taken in turn from each + * medium so the strip is not all one kind. + */ +export function constellation(items: Item[], max = 7): Item[] { + const favs = items.filter((i) => i.data.favorite && i.data.cover).sort(byStanding); + const byMedium = new Map(); + for (const i of favs) byMedium.set(i.data.medium, [...(byMedium.get(i.data.medium) ?? []), i]); + const order = mediaGroups.flatMap((g) => g.media as readonly string[]); + const queues = [...byMedium.entries()].sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([, q]) => q); + const out: Item[] = []; + while (out.length < max && queues.some((q) => q.length)) { + for (const q of queues) { + const next = q.shift(); + if (next && out.length < max) out.push(next); + } + } + return out; +} + +// ---------------------------------------------------------------- Shelf + +export const sorts = [ + { key: "played", label: "Last played" }, + { key: "az", label: "A to Z" }, + { key: "added", label: "Recently added" }, +] as const; +export type SortKey = (typeof sorts)[number]["key"]; + +export function sortGames(games: Game[], sort: SortKey): Game[] { + const az = (a: Game, b: Game) => a.title.localeCompare(b.title); + const desc = (x?: string, y?: string) => (y ?? "").localeCompare(x ?? ""); + const cmp: Record number> = { + 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), + }; + return [...games].sort(cmp[sort]); +} + +export interface ShelfFilter { + q: string; + plays: Set; + owned: boolean; + want: boolean; + favorites: boolean; + sort: SortKey; +} + +export function shelfFilter(params: URLSearchParams): ShelfFilter { + const submitted = params.has("f"); + return { + q: (params.get("q") ?? "").trim().toLowerCase(), + plays: new Set(params.getAll("plays").map((p) => p.toLowerCase())), + owned: submitted ? params.get("owned") === "1" : true, + want: params.get("want") === "1", + favorites: params.get("fav") === "1", + 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. + */ +export function gameShows(g: Game, f: ShelfFilter, text: string): 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.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false; + return !f.q || text.includes(f.q); +} + +/** Every "how it plays" value on the shelf, A to Z. */ +export const playStyles = (games: Game[]) => + [...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b)); + +/** Statuses about a board game: what is on the table now. */ +export const onTheTable = (s: SiteIndex) => + s + .statuses() + .map((status) => ({ status, game: s.byName(status.data.subject) })) + .filter((x): x is { status: Note<"status">; game: Game } => x.game?.type === "board-game"); diff --git a/services/site/src/lib/markdown.ts b/services/site/src/lib/markdown.ts index 0457823..b512d57 100644 --- a/services/site/src/lib/markdown.ts +++ b/services/site/src/lib/markdown.ts @@ -32,6 +32,7 @@ export function renderMarkdown( opts: { link: (name: string) => { title: string; href: string | null }; imageSrc: (file: string) => string | undefined }, ): string { const withLinks = body + .replace(//g, "") .replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, f: string, alt?: string) => `![${alt ?? ""}](${opts.imageSrc(f.trim()) ?? ""})`) .replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => { const l = opts.link(n.trim()); diff --git a/services/site/src/lib/resume-pdf.ts b/services/site/src/lib/resume-pdf.ts new file mode 100644 index 0000000..cdbfe31 --- /dev/null +++ b/services/site/src/lib/resume-pdf.ts @@ -0,0 +1,215 @@ +/** + * The printable resume. Built from the same ResumeData as the /resume page, + * laid out as one plain column so applicant tracking systems read it in order. + * Paper colors: ink on white, with the brand gold and teal darkened for print. + */ +import PDFDocument from "pdfkit"; +import cinzelBold from "../assets/pdf-fonts/Cinzel-Bold.ttf?inline"; +import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline"; +import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline"; +import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline"; +import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline"; +import { month, period, type ResumeData } from "./resume.ts"; + +const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64"); +const FONTS = { + title: fromDataUrl(cinzelBold), + heading: fromDataUrl(cinzelSemi), + body: fromDataUrl(montReg), + medium: fromDataUrl(montMed), + strong: fromDataUrl(montSemi), +}; + +const INK = "#131c2b"; +const MUTED = "#566173"; +const GOLD = "#a8842f"; +const TEAL = "#0f7f78"; +const RULE = "#d9cfb8"; + +const PAGE = { size: "LETTER" as const, margin: 50 }; +const SITE = "gregkilmer.com"; + +export function renderResumePdf(r: ResumeData): Promise { + const doc = new PDFDocument({ + size: PAGE.size, + margins: { top: PAGE.margin, bottom: PAGE.margin + 14, left: PAGE.margin, right: PAGE.margin }, + bufferPages: true, + info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" }, + lang: "en-US", + displayTitle: true, + }); + for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data); + + const left = doc.page.margins.left; + const width = doc.page.width - left - doc.page.margins.right; + const chunks: Buffer[] = []; + doc.on("data", (c: Buffer) => chunks.push(c)); + const done = new Promise((resolve, reject) => { + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + }); + + /** Starts a new page when fewer than `need` points remain. */ + const room = (need: number) => { + if (doc.y + need > doc.page.height - doc.page.margins.bottom) doc.addPage(); + }; + + const diamond = (x: number, y: number, s: number, color = GOLD) => { + doc.save().moveTo(x, y - s).lineTo(x + s, y).lineTo(x, y + s).lineTo(x - s, y).closePath().fill(color).restore(); + }; + + /** A thin gold rule with a small star at its center. */ + const starRule = (y: number) => { + const mid = left + width / 2; + doc.save().lineWidth(0.6).strokeColor(GOLD); + doc.moveTo(left, y).lineTo(mid - 10, y).stroke(); + doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke(); + doc.restore(); + diamond(mid, y, 4); + }; + + const section = (title: string) => { + room(60); + doc.moveDown(0.75); + const y = doc.y; + doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 }); + const ruleY = doc.y + 2; + doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore(); + doc.y = ruleY + 8; + }; + + const bullet = (text: string, indent = 12) => { + doc.font("body").fontSize(9.5); + const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 }); + room(h + 4); + const y = doc.y; + diamond(left + 3.5, y + 6, 2.2); + doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 }); + doc.moveDown(0.15); + }; + + // Header + doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 }); + if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width }); + // Only the professional profiles belong on paper; the hobby links stay on the site. + const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`)); + const contact = [r.location, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · "); + doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width }); + if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width }); + doc.moveDown(0.7); + starRule(doc.y); + doc.moveDown(0.6); + + if (r.summary) { + doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 }); + } + + if (r.stats.length) { + section("Highlights"); + const cols = Math.min(r.stats.length, 4); + const colW = width / cols; + let top = doc.y; + r.stats.forEach((st, i) => { + if (i > 0 && i % cols === 0) top = doc.y + 6; + const x = left + (i % cols) * colW; + doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 }); + doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 }); + if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 }); + }); + doc.x = left; + } + + if (r.roles.length) { + section("Experience"); + r.roles.forEach((role, i) => { + const d = role.data; + if (i > 0) doc.moveDown(0.6); + room(64); + const y = doc.y; + const when = period(role); + doc.font("medium").fontSize(9).fillColor(GOLD); + const whenW = doc.widthOfString(when); + doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false }); + doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 }); + const at = [d.company, d.location].filter(Boolean).join(" · "); + doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width }); + if (d.previousRoles?.length) { + doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width }); + } + if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 }); + doc.moveDown(0.3); + for (const b of d.bullets ?? []) bullet(b); + if (d.tech?.length) { + doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 }); + } + doc.x = left; + }); + } + + if (r.skills?.stack.length) { + section("Skills"); + for (const g of r.skills.stack) { + const items = g.items.join(", "); + doc.font("body").fontSize(9.5); + room(doc.heightOfString(items, { width: width - 110 }) + 4); + const y = doc.y; + doc.font("strong").fontSize(9.5).fillColor(INK).text(g.label, left, y, { width: 100 }); + const afterLabel = doc.y; + doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 }); + doc.y = Math.max(doc.y, afterLabel) + 3; + } + if (r.skills.core?.length) { + room(30); + const y = doc.y; + doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 }); + doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 }); + } + doc.x = left; + } + + if (r.education?.degrees.length) { + section("Education"); + for (const dg of r.education.degrees) { + room(40); + const y = doc.y; + doc.font("medium").fontSize(9).fillColor(GOLD); + const w = doc.widthOfString(dg.period); + doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false }); + doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 }); + doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width }); + if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width }); + doc.moveDown(0.4); + } + } + + if (r.education?.certifications?.length) { + section("Certifications"); + for (const c of r.education.certifications) { + room(20); + const line = [c.issuer, c.year].filter(Boolean).join(" · "); + doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line }); + if (line) doc.font("body").fillColor(MUTED).text(` ${line}`); + } + } + + // Footer on every page. + const range = doc.bufferedPageRange(); + const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : ""; + for (let i = range.start; i < range.start + range.count; i++) { + doc.switchToPage(i); + const y = doc.page.height - PAGE.margin + 4; + const bottom = doc.page.margins.bottom; + doc.page.margins.bottom = 0; + doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, y - 8).lineTo(left + width, y - 8).stroke().restore(); + doc.font("body").fontSize(8).fillColor(MUTED); + doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), left, y, { lineBreak: false }); + if (range.count > 1) { + const label = `${i - range.start + 1} of ${range.count}`; + doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false }); + } + doc.page.margins.bottom = bottom; + } + + doc.end(); + return done; +} diff --git a/services/site/src/lib/resume.ts b/services/site/src/lib/resume.ts new file mode 100644 index 0000000..643b1dd --- /dev/null +++ b/services/site/src/lib/resume.ts @@ -0,0 +1,49 @@ +/** The resume's data, shared by the page and the PDF so they never disagree. */ +import type { Note } from "./content.ts"; +import type { SiteIndex } from "./site.ts"; + +export interface ResumeData { + name: string; + headline: string; + location?: string; + summary?: string; + availability?: string; + stats: { value: string; label: string; detail?: string }[]; + roles: Note<"experience">[]; + skills?: Note<"skills">["data"]; + education?: Note<"education">["data"]; + socials: { label: string; handle: string; url: string }[]; + /** The newest change to any note the resume is built from (YYYY-MM-DD). */ + updated: string; +} + +/** "Mar 2019", from "2019-03". */ +export const month = (ym: string | undefined) => { + if (!ym) return "Present"; + const [y, m] = ym.split("-").map(Number); + return new Date(Date.UTC(y!, (m ?? 1) - 1, 1)).toLocaleDateString("en-US", { timeZone: "UTC", month: "short", year: "numeric" }); +}; + +export const period = (r: Note<"experience">) => `${month(r.data.start)} – ${month(r.data.end)}`; + +export function resumeData(s: SiteIndex): ResumeData { + const settings = s.settings(); + const page = s.pageText("/resume"); + const roles = [...s.all("experience")].sort((a, b) => (b.data.end ?? "9999").localeCompare(a.data.end ?? "9999") || b.data.start.localeCompare(a.data.start)); + const current = roles.find((r) => !r.data.end) ?? roles[0]; + const sources: Note[] = [...roles, ...(page ? [page] : []), ...[s.one("skills"), s.one("education")].filter((n): n is Note<"skills"> | Note<"education"> => !!n)]; + const updated = sources.map((n) => n.acceptedAt?.slice(0, 10) ?? "").sort().at(-1) ?? ""; + return { + name: settings?.name ?? "Greg Kilmer", + headline: page?.data.lede ?? current?.data.role ?? "", + location: settings?.location, + summary: page?.body?.trim() || undefined, + availability: settings?.availability, + stats: page?.data.stats ?? [], + roles, + skills: s.one("skills")?.data, + education: s.one("education")?.data, + socials: settings?.socials ?? [], + updated, + }; +} diff --git a/services/site/src/lib/site.ts b/services/site/src/lib/site.ts index 30c4d36..b40481d 100644 --- a/services/site/src/lib/site.ts +++ b/services/site/src/lib/site.ts @@ -218,10 +218,11 @@ export function mediaSrc(value: string | undefined): string | undefined { return `/media/${encodeURIComponent(file.trim())}`; } -/** Status kinds name icons; older notes use Material names, mapped here to brand icons. */ +/** Notes name icons; older notes use Material names, mapped here to brand icons. */ const MDI: Record = { "book-open-variant": "bookshelf", "book-open-page-variant": "bookshelf", + notebook: "bookshelf", headphones: "play", "television-classic": "tv", television: "tv", @@ -229,11 +230,33 @@ const MDI: Record = { "dice-multiple": "dice", "hammer-wrench": "hammer", school: "graduation", + "map-marker": "location", + "map-marker-outline": "location", + "briefcase-outline": "briefcase", + paw: "dog", + "cup-outline": "tea", + coffee: "tea", + "home-outline": "home", + "heart-outline": "heart", + "code-tags": "web", + laptop: "monitor", + "cards-playing": "proxy-cards", + "cards-playing-outline": "proxy-cards", + cards: "proxy-cards", + "printer-3d-nozzle": "print", + "printer-3d": "print", + printer: "print", }; -export function brandIcon(name: string | undefined): string { - if (!name) return "favorite"; - return MDI[name] ?? name; +/** The brand icons in @gk/ui. */ +const ICONS = new Set( + Object.keys(import.meta.glob("../../../../packages/ui/src/assets/icons/*.svg")).map((p) => p.replace(/^.*\/(.+)\.svg$/, "$1")), +); + +/** A brand icon for a note's icon name, or the fallback when none matches. */ +export function brandIcon(name: string | undefined, fallback = "favorite"): string { + const mapped = name ? (MDI[name] ?? name) : fallback; + return ICONS.has(mapped) ? mapped : fallback; } /** "Oct 11, 2026" from "2026-10-11". */ diff --git a/services/site/src/lib/store.ts b/services/site/src/lib/store.ts new file mode 100644 index 0000000..18bcc9d --- /dev/null +++ b/services/site/src/lib/store.ts @@ -0,0 +1,133 @@ +/** + * The Store's rules in one place: which lines show, in what order, whether a + * line can take an order, its status line and its price summary. + */ +import type { Note } from "./content.ts"; +import type { SiteIndex } from "./site.ts"; +import { brandIcon, formatDate } from "./site.ts"; + +export type Line = Note<"product-line">; +export type Category = Line["data"]["category"]; +export type Channel = Line["data"]["channel"]; +type Settings = Note<"store-settings">["data"]; +export type CategorySettings = Settings["categories"][number]; +export type Term = NonNullable[number]; + +export const CATEGORIES: Category[] = ["Tabletop", "Paper", "Leather"]; + +/** Proxy cards are sold locally only, never on Etsy. */ +export function isProxy(line: Line): boolean { + return /prox/i.test(`${line.title} ${line.slug} ${line.data.summary}`); +} + +/** + * Where a line is sold. A proxy line a note marks as Etsy is treated as sold + * here, since proxies are never sold on Etsy. + */ +export function channelOf(line: Line): Channel { + if (line.data.channel === "Etsy" && isProxy(line)) return "here"; + return line.data.channel; +} + +/** The shop link for a line sold elsewhere, never for a proxy line on Etsy. */ +export function shopLink(line: Line): { href: string; label: string } | null { + const ch = channelOf(line); + if ((ch !== "Etsy" && ch !== "Patiun.com") || !line.data.channelUrl) return null; + return { href: line.data.channelUrl, label: `Shop on ${ch}` }; +} + +export interface StoreState { + lines: Line[]; + groups: [Category, Line[]][]; + settings: Settings | undefined; + category(name: Category): CategorySettings; + /** Categories that have a line sold here and are closed. */ + closed: CategorySettings[]; + /** True when some line sold here sits in an open category. */ + anyOpen: boolean; + terms: Term[]; +} + +export function storeState(s: SiteIndex): StoreState { + const settings = s.one("store-settings")?.data; + const lines = [...s.all("product-line")].sort( + (a, b) => + CATEGORIES.indexOf(a.data.category) - CATEGORIES.indexOf(b.data.category) || + (a.data.order ?? 999) - (b.data.order ?? 999) || + a.title.localeCompare(b.title), + ); + const category = (name: Category): CategorySettings => + settings?.categories.find((c) => c.name === name) ?? { name, ordersOpen: false }; + const groups = CATEGORIES.map((c) => [c, lines.filter((l) => l.data.category === c)] as [Category, Line[]]).filter(([, ls]) => ls.length); + const sellingCats = CATEGORIES.filter((c) => lines.some((l) => l.data.category === c && channelOf(l) === "here")); + return { + lines, + groups, + settings, + category, + closed: sellingCats.map(category).filter((c) => !c.ordersOpen), + anyOpen: sellingCats.some((c) => category(c).ordersOpen), + terms: settings?.terms ?? [], + }; +} + +/** Whether a line has room: open slots when made to order, stock when in stock. */ +export function hasCapacity(line: Line): boolean { + return line.data.mode === "made to order" ? (line.data.slots?.open ?? 0) > 0 : (line.data.quantity ?? 0) > 0; +} + +/** Whether the order button shows for this line. */ +export function canOrder(line: Line, st: StoreState): boolean { + return channelOf(line) === "here" && st.category(line.data.category).ordersOpen && hasCapacity(line); +} + +/** A short status: slots, stock, or where it is sold. */ +export function statusText(line: Line): string { + const ch = channelOf(line); + if (ch === "coming soon") return "Coming soon"; + if (ch !== "here") return `Sold on ${ch}`; + if (line.data.mode === "made to order") { + const sl = line.data.slots; + return sl ? `${Math.min(sl.open, sl.total)} of ${sl.total} ${sl.total === 1 ? "slot" : "slots"} open` : "Made to order"; + } + const q = line.data.quantity ?? 0; + return q > 0 ? `${q} in stock` : "Sold out"; +} + +/** "Orders closed. Reopens Oct 11, 2026." for a category. */ +export function closedText(c: CategorySettings): string { + return c.reopens ? `Reopens ${formatDate(c.reopens)}.` : ""; +} + +const money = (p: string) => { + const m = /^\s*\$?\s*([\d,]+(?:\.\d+)?)\s*$/.exec(p); + return m ? Number(m[1]!.replace(/,/g, "")) : null; +}; + +/** The lowest price, its note, and up to two other priced tiers. */ +export function priceSummary(line: Line): { from: string; note?: string; more: string[] } | null { + const priced = line.data.priceTiers.map((t, i) => ({ ...t, i, n: money(t.price) })).filter((t) => t.n !== null); + if (!priced.length) return null; + const low = priced.reduce((a, b) => (b.n! < a.n! ? b : a)); + const more = priced + .filter((t) => t !== low) + .sort((a, b) => b.n! - a.n!) + .slice(0, 2) + .sort((a, b) => a.i - b.i) + .map((t) => `${t.band} ${t.price}`); + return { from: low.price, note: low.note, more }; +} + +/** Option groups in note order, with their options. */ +export function optionGroups(line: Line): [string, NonNullable][] { + const map = new Map>(); + for (const o of line.data.options ?? []) map.set(o.group, [...(map.get(o.group) ?? []), o]); + return [...map.entries()]; +} + +const CATEGORY_ICONS: Record = { Tabletop: "dice", Paper: "bookshelf", Leather: "leather" }; + +/** A brand icon for a line: its own when it maps to one, else its category's. */ +export function lineIcon(line: Line): string { + return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]); +} diff --git a/services/site/src/lib/work.ts b/services/site/src/lib/work.ts new file mode 100644 index 0000000..292b44e --- /dev/null +++ b/services/site/src/lib/work.ts @@ -0,0 +1,114 @@ +/** Helpers for the Work index and the Inquiries pages. */ +import type { Note } from "./content.ts"; +import type { SiteIndex } from "./site.ts"; + +export type WorkGroup = "employer" | "personal" | "homelab"; + +export const groupLabels: Record = { + employer: "Employer", + personal: "Personal", + homelab: "Homelab", +}; + +/** + * Which group a case study belongs to: employer work has an employer; a + * project without one is homelab when it is tagged or described as such, + * otherwise personal. + */ +export function workGroup(p: Note<"post">): WorkGroup { + if (p.data.employer) return "employer"; + const tags = (p.data.tags ?? []).map((t) => t.toLowerCase()); + if (tags.includes("homelab") || /\bhome\s?lab\b/i.test(`${p.data.summary} ${p.body}`)) return "homelab"; + return "personal"; +} + +/** "Implico Inc." as "Implico" for kickers. */ +export function shortEmployer(name: string): string { + return name.replace(/,?\s+(Inc\.?|LLC|Ltd\.?|Corp\.?|GmbH)$/i, "").trim(); +} + +/** The gold kicker over a case study: "Employer · Implico", "Homelab". */ +export function workKicker(p: Note<"post">): string { + const g = workGroup(p); + return g === "employer" && p.data.employer ? `${groupLabels[g]} · ${shortEmployer(p.data.employer)}` : groupLabels[g]; +} + +/** + * The featured study: the newest one marked favorite or pinned, else the + * newest. Same-day ties go to employer work, then to the one with more facts. + */ +export function featuredStudy(studies: Note<"post">[]): Note<"post"> | undefined { + const marked = studies.filter((p) => p.data.favorite || p.data.pinned); + const pool = marked.length ? marked : studies; + const newest = pool[0]?.data.date; + return pool + .filter((p) => p.data.date === newest) + .sort((a, b) => Number(!!b.data.employer) - Number(!!a.data.employer) || (b.data.facts?.length ?? 0) - (a.data.facts?.length ?? 0))[0]; +} + +const groupOrder: WorkGroup[] = ["employer", "homelab", "personal"]; + +/** The rest of the studies in group order (employer, homelab, personal), newest first within a group. */ +export function otherStudies(studies: Note<"post">[], featured: Note<"post"> | undefined): Note<"post">[] { + return studies + .filter((p) => p !== featured) + .map((p, i) => ({ p, i })) + .sort((a, b) => groupOrder.indexOf(workGroup(a.p)) - groupOrder.indexOf(workGroup(b.p)) || a.i - b.i) + .map((x) => x.p); +} + +/** Tech the resume outlines as the core stack, lower-cased, for the teal-edged chips. */ +export function coreTech(s: SiteIndex): Set { + const stack = s.one("skills")?.data.stack ?? []; + return new Set(stack.flatMap((g) => g.core ?? []).map((t) => t.toLowerCase())); +} + +// ---------------------------------------------------------------- Inquiries + +export type InquiryType = "job" | "freelance" | "patiun"; + +export const inquiryTypes: { value: InquiryType; label: string; blurb: string; summary: string }[] = [ + { value: "job", label: "A job or role", blurb: "Full-time roles and contracts", summary: "A job or role" }, + { value: "freelance", label: "Freelance web work", blurb: "Sites, apps and integrations", summary: "Freelance web work" }, + { value: "patiun", label: "Custom Patiun order", blurb: "Cards, deck boxes and more", summary: "Patiun custom order" }, +]; + +/** `?type=` as an inquiry type; also accepts a few natural spellings. */ +export function parseInquiryType(raw: string | null | undefined): InquiryType | undefined { + const v = (raw ?? "").trim().toLowerCase(); + if (!v) return undefined; + if (["job", "role", "jobs", "hire", "hiring"].includes(v)) return "job"; + if (["freelance", "web", "freelance-web"].includes(v)) return "freelance"; + if (["patiun", "order", "custom", "custom-order", "store"].includes(v)) return "patiun"; + return undefined; +} + +/** + * What the sent page shows under "What you sent". The inquiries API (phase 6) + * sets this as the short-lived `gk-inquiry` cookie (URL-encoded JSON) before + * redirecting to /inquiries/sent. + */ +export interface SentSummary { + type?: InquiryType; + name?: string; + email?: string; + /** Extra rows in order, like { label: "How many", value: "60 cards" }. */ + rows?: { label: string; value: string }[]; +} + +export function parseSentSummary(raw: string | undefined): SentSummary | undefined { + if (!raw) return undefined; + try { + const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record; + const str = (x: unknown) => (typeof x === "string" && x.trim() ? x.trim().slice(0, 300) : undefined); + const rows = Array.isArray(v.rows) + ? v.rows + .map((r) => ({ label: str((r as Record)?.label), value: str((r as Record)?.value) })) + .filter((r): r is { label: string; value: string } => !!r.label && !!r.value) + .slice(0, 8) + : []; + return { type: parseInquiryType(str(v.type)), name: str(v.name), email: str(v.email), rows }; + } catch { + return undefined; + } +} diff --git a/services/site/src/lib/world.ts b/services/site/src/lib/world.ts new file mode 100644 index 0000000..80cf142 --- /dev/null +++ b/services/site/src/lib/world.ts @@ -0,0 +1,97 @@ +/** + * Helpers for the Workshop, craft and Tabletop pages: ordering, craft seals, + * make photos, the sword rack, and which pill a state wears. + */ +import type { SealName } from "@gk/ui/components/seals.ts"; +import type { Note } from "./content.ts"; +import { mediaSrc, type SiteIndex } from "./site.ts"; + +type Pill = "live" | "state" | "current" | "neutral"; + +/** By `order`, notes without one last, then by title. */ +export function byOrder(notes: T[]): T[] { + const o = (n: Note) => ((n.data as { order?: number }).order ?? Number.MAX_SAFE_INTEGER); + return [...notes].sort((a, b) => o(a) - o(b) || a.title.localeCompare(b.title)); +} + +/** Newest `date` first; undated notes keep their order after the dated ones. */ +export function byDateDesc(notes: T[]): T[] { + const d = (n: Note) => String((n.data as { date?: string }).date ?? ""); + return [...notes].sort((a, b) => d(b).localeCompare(d(a))); +} + +/** A craft's own seal: dice crafts, leather, print and paper. Undefined means the family seal. */ +export function craftSeal(craft: Note<"craft">): SealName | undefined { + const text = `${craft.title} ${craft.data.blurb ?? ""}`.toLowerCase(); + if (/leather/.test(text)) return "craft-leather"; + if (/dice|resin/.test(text)) return "craft-dice"; + if (/print|paper|bookbind|card|decal/.test(text)) return "craft-print"; + return undefined; +} + +/** A make's photos as image addresses (the `photos` list, else its `cover`). */ +export function makePhotos(make: Note<"make">): string[] { + const d = make.data as { photos?: string[]; cover?: string }; + const list = d.photos?.length ? d.photos : d.cover ? [d.cover] : []; + return list.map((p) => mediaSrc(p)).filter((x): x is string => !!x); +} + +/** Makes tagged for the sword rack. */ +export function isSword(make: Note<"make">): boolean { + const tags = make.data.tags ?? []; + return tags.some((t) => /sword/i.test(t)); +} + +/** The makes of a craft. */ +export function makesOf(s: SiteIndex, craft: Note<"craft">): Note<"make">[] { + return s.all("make").filter((m) => s.byName(m.data.craft)?.path === craft.path); +} + +/** Build logs for a craft: posts linking to it or its makes. */ +export function buildLogs(s: SiteIndex, craft: Note<"craft">): Note<"post">[] { + return s.backlinks(craft, { type: "post" }) as Note<"post">[]; +} + +/** The tools of a craft, as notes where published, in the craft's order. */ +export function toolsOf(s: SiteIndex, craft: Note<"craft">): { title: string; note?: Note<"tool"> }[] { + return (craft.data.tools ?? []).map((t) => { + const l = s.link(t); + return { title: l.title, note: l.note?.type === "tool" ? (l.note as Note<"tool">) : undefined }; + }); +} + +/** The tools placed at a station. */ +export function toolsAt(s: SiteIndex, station: Note<"station">): Note<"tool">[] { + return s.all("tool").filter((t) => s.byName(t.data.station)?.path === station.path); +} + +export const craftPill = (status: string): Pill => (status === "Paused" ? "neutral" : "live"); + +export const systemPill = (state: string): Pill => (state === "running" ? "live" : "neutral"); + +/** Card game stances: the main event is gold, active ones live, the rest plain. */ +export function stancePill(stance: string): Pill { + const s = stance.toLowerCase(); + if (s === "main event") return "current"; + if (/getting into|exploring|playing|learning/.test(s)) return "live"; + return "neutral"; +} + +/** "want to run" -> "Want to run". */ +export const sentence = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +/** Order systems are grouped in. */ +export const systemStates = ["running", "played", "want to run", "want to play", "want to try"] as const; + +/** Statuses whose kind belongs to a page (Making and Learning for /workshop). */ +export function statusesFor(s: SiteIndex, page: string) { + return s.statuses().filter((st) => s.kind(st.data.kind).page === page); +} + +/** Archidekt deck address. */ +export const archidektUrl = (id: number) => `https://archidekt.com/decks/${id}`; + +/** Deck's list address: Archidekt when set, else its own list link. */ +export function deckUrl(deck: Note<"deck">): string | undefined { + return deck.data.archidektId ? archidektUrl(deck.data.archidektId) : deck.data.listUrl; +} diff --git a/services/site/src/pages/404.astro b/services/site/src/pages/404.astro new file mode 100644 index 0000000..c2487d4 --- /dev/null +++ b/services/site/src/pages/404.astro @@ -0,0 +1,162 @@ +--- +/** + * Off the star chart. Renders even when the content service is down: the + * only data it reads (an optional Page text note for /404) is wrapped. + */ +import Button from "@gk/ui/components/Button.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StarPath from "@gk/ui/components/StarPath.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import { site } from "../lib/page.ts"; + +Astro.response.status = 404; + +let lede = "This page isn't here. It may have moved when the site was rebuilt."; +let timer: ReturnType | undefined; +try { + const s = await Promise.race([site(), new Promise((_, reject) => (timer = setTimeout(() => reject(new Error("timeout")), 2000)))]); + lede = s.pageText("/404")?.data.lede ?? lede; +} catch { + // The content service is unreachable; the page needs nothing from it. +} finally { + clearTimeout(timer); +} + +const groups = [ + { id: "nf-work", label: "Work with me", links: [["Resume", "/resume"], ["Work", "/work"], ["Inquiries", "/inquiries"]] }, + { id: "nf-world", label: "My world", links: [["Library", "/library"], ["Workshop", "/workshop"], ["Tabletop", "/tabletop"], ["Now", "/now"]] }, + { id: "nf-also", label: "Also", links: [["Blog", "/blog"], ["Store", "/store"], ["About", "/about"]] }, +]; +--- + + +
    + + 404 · Page not found +

    Off the star chart

    +

    {lede}

    +
    + + +
    +
    + + + + +
    + + diff --git a/services/site/src/pages/about.astro b/services/site/src/pages/about.astro new file mode 100644 index 0000000..af06330 --- /dev/null +++ b/services/site/src/pages/about.astro @@ -0,0 +1,435 @@ +--- +/** + * About me: the "I am Greg" intro beside the portrait, the longer story with + * quick facts and what is happening right now, the two wings of the site, and + * where else to find Greg. Text from the Page text note for /about. + */ +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 TickScale from "@gk/ui/components/TickScale.astro"; +import PageHero from "../components/PageHero.astro"; +import DoorLink from "../components/home/DoorLink.astro"; +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"; + +const s = await site(); +const text = s.pageText("/about"); +const settings = s.settings(); +const { intro, sections } = splitIntro(text?.body ?? ""); +const introHtml = s.render(intro); +const facts = text?.data.quickFacts ?? []; +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 ?? []; +const SOCIAL_ICONS: Record = { github: "web", linkedin: "briefcase", archidekt: "proxy-cards" }; +--- + + + +
    +
    + {introHtml ?
    :

    [PLACEHOLDER]

    } +
    +
    + +
    +
    + +
    +
    + { + sections.map((sec, i) => ( +
    + + + {sec.heading} + +
    +
    + )) + } +
    + + +
    + +
    + + + Two wings of the same house + +
    + +
    + +
    + For friends and fellow nerds +

    My world

    + +
    +
    +
    +
    +
    + + diff --git a/services/site/src/pages/blog/[slug].astro b/services/site/src/pages/blog/[slug].astro new file mode 100644 index 0000000..ead0c0a --- /dev/null +++ b/services/site/src/pages/blog/[slug].astro @@ -0,0 +1,29 @@ +--- +/** + * One blog post in the all-dark reading layout. A case study's slug answers + * with a 301 to /work/; an old slug follows its redirect. + */ +import PostLayout from "../../components/blog/PostLayout.astro"; +import { topicHref } from "../../lib/blog.ts"; +import { site } from "../../lib/page.ts"; + +const s = await site(); +const slug = Astro.params.slug ?? ""; +const post = s.bySlug("post", slug); +if (!post) { + const to = s.redirect("post", slug); + return to ? Astro.redirect(to, 301) : Astro.rewrite("/404"); +} +if (post.data.postType === "case study") return Astro.redirect(`/work/${post.slug}`, 301); + +const list = s.posts(); +--- + diff --git a/services/site/src/pages/blog/index.astro b/services/site/src/pages/blog/index.astro new file mode 100644 index 0000000..f91beda --- /dev/null +++ b/services/site/src/pages/blog/index.astro @@ -0,0 +1,169 @@ +--- +/** + * The Blog: the featured or latest post, topic pills, search, the posts as + * rows, and the sidebar (Right now, Topics, Archive, RSS). Case studies live + * on Work and are not listed here. + */ +import PageShell from "@gk/ui/components/PageShell.astro"; +import Button from "@gk/ui/components/Button.astro"; +import FilterGroup from "@gk/ui/components/FilterGroup.astro"; +import Icon from "@gk/ui/components/Icon.astro"; +import SearchField from "@gk/ui/components/SearchField.astro"; +import PageHero from "../../components/PageHero.astro"; +import BlogLayout from "../../components/blog/BlogLayout.astro"; +import LeadPost from "../../components/blog/LeadPost.astro"; +import PostList from "../../components/blog/PostList.astro"; +import RightNowPanel from "../../components/blog/RightNowPanel.astro"; +import RowList from "../../components/blog/RowList.astro"; +import SidePanel from "../../components/blog/SidePanel.astro"; +import StarHeading from "../../components/blog/StarHeading.astro"; +import TopicsPanel from "../../components/blog/TopicsPanel.astro"; +import { archive, leadPost, searchPosts, topicHref, topics } from "../../lib/blog.ts"; +import { site } from "../../lib/page.ts"; +import { formatDate } from "../../lib/site.ts"; + +const PER_PAGE = 10; +const s = await site(); +const all = s.posts(); +const params = Astro.url.searchParams; +const q = (params.get("q") ?? "").trim().slice(0, 100); +const month = /^\d{4}-\d{2}$/.test(params.get("month") ?? "") ? params.get("month")! : ""; +const page = Math.max(1, Number.parseInt(params.get("page") ?? "1", 10) || 1); +const filtered = !!(q || month); + +let list = searchPosts(all, q); +if (month) list = list.filter((p) => p.data.date.startsWith(month)); +const lead = !filtered && page === 1 ? leadPost(all) : undefined; +if (lead) list = list.filter((p) => p.path !== lead.post.path); +const shown = list.slice((page - 1) * PER_PAGE, page * PER_PAGE); +const more = list.length > page * PER_PAGE; + +const keep = (extra: Record) => { + const u = new URLSearchParams(); + if (q) u.set("q", q); + if (month) u.set("month", month); + for (const [k, v] of Object.entries(extra)) u.set(k, String(v)); + return `/blog?${u}`; +}; + +const text = s.pageText("/blog"); +const lede = text?.data.lede ?? "What I am making, learning, playing and building, and how it went."; +const months = archive(all); +const heading = q ? `Posts matching “${q}”` : month ? `Posts from ${formatDate(`${month}-01`, { month: "long", day: false })}` : lead ? "More posts" : "Posts"; + +const empty = all.length === 0 + ? { + emptyTitle: "The first posts are on their way", + emptyText: "Nothing is published here yet. Until then, the Now page shows what I am reading, playing and making.", + emptyAction: { label: "See what I am into now", href: "/now" }, + } + : lead && list.length === 0 && !filtered + ? { emptyTitle: "That is the only post so far", emptyText: "More are on their way." } + : { emptyTitle: "No posts match", emptyText: "Try another word, or browse by topic.", emptyAction: { label: "Show every post", href: "/blog" } }; +--- + + + + + + + + + { + lead && ( +
    + +
    + ) + } + + +
    +
    + {heading} + {list.length > 0 && Sort: Newest} +
    + ({ label: t, href: topicHref(t) }))]} + /> +
    + + + + + { + months.length > 0 && ( + + ({ label: m.label, href: `/blog?month=${m.month}`, count: m.count, current: m.month === month }))} /> + + ) + } + RSS feed + +
    +
    + + diff --git a/services/site/src/pages/blog/topic/[topic].astro b/services/site/src/pages/blog/topic/[topic].astro new file mode 100644 index 0000000..c4bafd7 --- /dev/null +++ b/services/site/src/pages/blog/topic/[topic].astro @@ -0,0 +1,156 @@ +--- +/** + * A blog topic: its posts as rows, filterable by the page they link to, and + * a sidebar naming where the topic lives on the site and every topic. + */ +import PageShell from "@gk/ui/components/PageShell.astro"; +import Button from "@gk/ui/components/Button.astro"; +import FilterGroup from "@gk/ui/components/FilterGroup.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import BlogLayout from "../../../components/blog/BlogLayout.astro"; +import CrumbHero from "../../../components/blog/CrumbHero.astro"; +import PostList from "../../../components/blog/PostList.astro"; +import TopicsPanel from "../../../components/blog/TopicsPanel.astro"; +import { pageLinks, topicFromSlug, topicHome, topicHref } from "../../../lib/blog.ts"; +import { site } from "../../../lib/page.ts"; + +const PER_PAGE = 10; +const topic = topicFromSlug(Astro.params.topic); +if (!topic) return Astro.rewrite("/404"); + +const s = await site(); +const base = topicHref(topic); +const posts = s.posts({ topic }); + +// Filters: the pages this topic's posts link to, most posts first. +const byPage = new Map(); +for (const p of posts) { + for (const l of pageLinks(s, p)) { + if (l.note.type === "post") continue; + const cur = byPage.get(l.note.path) ?? { label: l.note.title, slug: l.note.slug, count: 0 }; + cur.count++; + byPage.set(l.note.path, cur); + } +} +const filters = [...byPage.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)).slice(0, 8); +const on = Astro.url.searchParams.get("on") ?? ""; +const active = filters.find((f) => f.slug === on); +const page = Math.max(1, Number.parseInt(Astro.url.searchParams.get("page") ?? "1", 10) || 1); + +const list = active ? posts.filter((p) => pageLinks(s, p).some((l) => l.note.slug === active.slug)) : posts; +const shown = list.slice((page - 1) * PER_PAGE, page * PER_PAGE); +const more = list.length > page * PER_PAGE; +const pageHref = (n: number) => `${base}?${new URLSearchParams({ ...(active ? { on: active.slug } : {}), page: String(n) })}`; + +const firstPage = (p: (typeof posts)[number]) => pageLinks(s, p).find((l) => l.note.type !== "post")?.note.title; +const kicker = (p: (typeof posts)[number]) => { + const sub = active?.label ?? firstPage(p); + return sub ? `${topic} · ${sub}` : topic; +}; + +const home = topicHome[topic]; +const lede = s.pageText(base)?.data.lede; +--- + + + + + + +
    + { + filters.length > 1 && ( + ({ label: f.label, href: `${base}?on=${f.slug}`, active: f === active })), + ]} + /> + ) + } + + { + (more || page > 1) && ( + + ) + } +
    + + + + + + This topic lives in + {home.label} + {home.blurb} + + + + +
    +
    + + diff --git a/services/site/src/pages/index.astro b/services/site/src/pages/index.astro index f9121d6..359bdbf 100644 --- a/services/site/src/pages/index.astro +++ b/services/site/src/pages/index.astro @@ -1,8 +1,363 @@ --- +/** + * The landing: Greg's name and one line, the two doors (work first), the Now + * strip from the status log and the newest posts. + */ +import Button from "@gk/ui/components/Button.astro"; +import GiltCorners from "@gk/ui/components/GiltCorners.astro"; +import Kicker from "@gk/ui/components/Kicker.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 StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import BlogStrip from "../components/home/BlogStrip.astro"; +import DoorLink from "../components/home/DoorLink.astro"; +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"; + const s = await site(); +const settings = s.settings(); +const text = s.pageText("/"); +const role = currentRole(s); +const skills = s.one("skills")?.data; + +const name = settings?.name ?? "Greg Kilmer"; +const roleTitle = role?.data.role.split(" / ").pop()?.trim(); +const eyebrow = [roleTitle, settings?.location].filter(Boolean).join(" · "); +const intro = text?.data.lede ? `

    ${text.data.lede}

    ` : s.render(text?.body); +const studies = s.caseStudies().map((c) => c.title); +const coreStack = skills?.stack.flatMap((g) => g.core ?? []) ?? []; +const photo = worldPhoto(s); +const statuses = s.statuses().slice(0, 5).map((st) => viewStatus(s, st)); +const listFormat = new Intl.ListFormat("en", { style: "long", type: "conjunction" }); --- - -

    {s.notes.length} notes, {s.statuses().length} statuses.

    + + +
    +
    + { + eyebrow && ( + + + ) + } +

    {name}

    + {intro ?
    :

    [PLACEHOLDER]

    } +
    +
    + +
    +
    + +
    +
    + + +
    +
    For employers and clients
    +
    +

    Work with me

    + +
    + {role?.data.focus &&

    {role.data.focus}

    } +
    + +
    + + {coreStack.length > 0 && {coreStack.join(" · ")}} + {settings?.availability && {settings.availability}} +
    +
    + +
    + +
    + + + For friends and fellow nerds +
    +

    My world

    + +
    + +
    +
    +
    + + +
    + + { + statuses.length ? ( +
      + {statuses.map((v) => ( + + ))} +
    + ) : ( +

    Nothing on the status log yet.

    + ) + } + +
    + + diff --git a/services/site/src/pages/inquiries/index.astro b/services/site/src/pages/inquiries/index.astro new file mode 100644 index 0000000..0b24410 --- /dev/null +++ b/services/site/src/pages/inquiries/index.astro @@ -0,0 +1,421 @@ +--- +import Button from "@gk/ui/components/Button.astro"; +import Field from "@gk/ui/components/Field.astro"; +import HeroHorizon from "@gk/ui/components/HeroHorizon.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import ChoicePills from "../../components/work/ChoicePills.astro"; +import { site } from "../../lib/page.ts"; +import { inquiryTypes, parseInquiryType, type InquiryType } from "../../lib/work.ts"; + +const s = await site(); +const settings = s.settings(); +const text = s.pageText("/inquiries"); +const selected = parseInquiryType(Astro.url.searchParams.get("type")); +const lede = text?.data.lede ?? "Roles, freelance web work or a custom Patiun order. Pick one and the form asks the right questions."; +const quickFacts = text?.data.quickFacts?.map((f) => f.text) ?? ["Replies within [PLACEHOLDER] days", "Read by me, not a bot"]; +const socials = settings?.socials ?? []; + +const placeholders: Record = { + none: "Tell me what you have in mind.", + job: "The role, the team, and anything I should know.", + freelance: "What you need built, who it is for, and any links.", + patiun: "Card list, deck colors, theme, or anything I should know.", +}; +const timing = [ + { value: "no-rush", label: "No rush" }, + { value: "within-a-month", label: "Within a month" }, + { value: "date", label: "A specific date" }, +]; +const env = (k: string) => (process.env[k] ?? import.meta.env[k]) as string | undefined; +const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY"); +--- + + {turnstileKey && } + +
    +
    + + Inquiries +

    Let's make something

    + +

    {lede}

    + {settings?.availability && {settings.availability}} + +
      + { + quickFacts.map((f) => ( +
    • + + {f} +
    • + )) + } + { + socials.length > 0 && ( +
    • + + + Also on{" "} + {socials.map((x, i) => ( + <> + {x.label} + {i < socials.length - 2 ? ", " : i === socials.length - 2 ? " and " : ""} + + ))} + +
    • + ) + } +
    +
    + +
    +
    +
    + What's this about? +
    + { + inquiryTypes.map((t) => ( + + )) + } +
    +
    + +
    + + +
    + +
    +
    + + +
    + + +
    + +
    + +
    + + +
    + +
    + +
    + +
    + + +
    + +
    + + + + + + {turnstileKey &&
    } + +
    + + {turnstileKey && Protected by Cloudflare Turnstile.} +
    +
    +
    +
    +
    + + + + diff --git a/services/site/src/pages/inquiries/sent.astro b/services/site/src/pages/inquiries/sent.astro new file mode 100644 index 0000000..eddf0cc --- /dev/null +++ b/services/site/src/pages/inquiries/sent.astro @@ -0,0 +1,229 @@ +--- +/** + * The confirmation after an inquiry. The inquiries API sets the short-lived + * `gk-inquiry` cookie (URL-encoded JSON: type, name, email, rows) before it + * redirects here; the page shows it once and clears it. Without the cookie + * the page still reads correctly, showing `?type=` when given. + */ +import Button from "@gk/ui/components/Button.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import { inquiryTypes, parseInquiryType, parseSentSummary } from "../../lib/work.ts"; + +const sent = parseSentSummary(Astro.cookies.get("gk-inquiry")?.value); +if (sent) Astro.cookies.delete("gk-inquiry", { path: "/" }); +Astro.response.headers.set("Cache-Control", "private, no-store"); + +const type = sent?.type ?? parseInquiryType(Astro.url.searchParams.get("type")); +const typeLabel = inquiryTypes.find((t) => t.value === type)?.summary; +const rows = [ + ...(typeLabel ? [{ label: "Type", value: typeLabel }] : []), + ...(sent?.rows ?? []), + ...(sent?.email ? [{ label: "Reply to", value: sent.email }] : []), +]; +const firstName = sent?.name?.split(/\s+/)[0]; +const steps = [ + { title: "I read it", text: "Usually within a day or two." }, + { title: "I reply", text: "With questions, a quote, or a time to talk." }, + { title: "We settle the details", text: "Nothing starts until you say yes." }, +]; +--- + + +
    +
    + + Inquiry sent +

    Your note is on its way

    +

    Thanks{firstName ? `, ${firstName}` : ""}. I read every inquiry myself and reply within [PLACEHOLDER] days.

    +
      + { + steps.map((st, i) => ( +
    1. + {i + 1} + + {st.title} + {st.text} + +
    2. + )) + } +
    +
    + +
    + { + rows.length > 0 && ( +
    +

    + + What you sent +

    + +
    + {rows.map((r) => ( +
    +
    {r.label}
    +
    {r.value}
    +
    + ))} +
    +
    + ) + } + +
    +
    +
    + + diff --git a/services/site/src/pages/library/index.astro b/services/site/src/pages/library/index.astro new file mode 100644 index 0000000..21d7458 --- /dev/null +++ b/services/site/src/pages/library/index.astro @@ -0,0 +1,255 @@ +--- +/** + * The Library: every book, series, show, film and video game. The favorites + * 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"; +import FilterLayout from "../../components/library/FilterLayout.astro"; +import LibraryHero from "../../components/library/LibraryHero.astro"; +import LibraryRow from "../../components/library/LibraryRow.astro"; +import ListHeading from "../../components/library/ListHeading.astro"; +import RailGroup from "../../components/library/RailGroup.astro"; +import { byStanding, constellation, libraryFilter, mediaGroupOf, mediaGroups, sectionOf, sections, searchText } from "../../lib/library.ts"; +import { site } from "../../lib/page.ts"; + +const s = await site(); +const f = libraryFilter(Astro.url.searchParams); +const items = s.all("library-item").filter((i) => sectionOf(i)); +const lede = s.pageText("/library")?.data.lede ?? "What I'm on now, what's next, and what stuck with me. Short verdicts, no scores."; + +const shows = (i: (typeof items)[number]) => + (!f.type || mediaGroupOf(i) === f.type) && + f.show.has(sectionOf(i)!) && + (!f.favorites || !!i.data.favorite) && + (!f.blog || s.backlinks(i, { type: "post" }).length > 0) && + (!f.q || searchText([i.title, i.data.creator, i.data.group, i.data.verdict, i.data.note, i.data.medium, i.data.platform, i.data.year, i.data.progress]).includes(f.q)); + +const groups = sections.map((sec) => { + const list = items.filter((i) => sectionOf(i) === sec.key).sort(byStanding); + return { ...sec, list, visible: list.filter(shows).length }; +}); +const anyVisible = groups.some((g) => g.visible > 0); +const dots = { now: "live", next: "open", done: "done" } as const; +const typeHref = (key?: string) => { + const p = new URLSearchParams(Astro.url.searchParams); + if (key) p.set("type", key); + else p.delete("type"); + const q = p.toString(); + return q ? `/library?${q}` : "/library"; +}; +/** Finished rows shown before "Show everything finished". */ +const FIRST = 12; +--- + + + + +
    + + + + + {f.type && } + + + {sections.map((sec) => )} + + + + + +
    + { + groups.map((g) => ( + + )) + } + +
    +
    +
    + + + + diff --git a/services/site/src/pages/library/shelf.astro b/services/site/src/pages/library/shelf.astro new file mode 100644 index 0000000..04fe24c --- /dev/null +++ b/services/site/src/pages/library/shelf.astro @@ -0,0 +1,369 @@ +--- +/** + * The Shelf: every board game. What is on the table (from the status log), + * then the collection, filtered by how it plays, owned, want to play and + * favorites, and sorted by last played, A to Z or recently added. Player + * counts and play times come from BoardGameGeek later; 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"; +import SearchField from "@gk/ui/components/SearchField.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 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 { site } from "../../lib/page.ts"; +import { formatDate, mediaSrc } from "../../lib/site.ts"; + +const s = await site(); +const f = shelfFilter(Astro.url.searchParams); +const all = s.all("board-game"); +const games = sortGames(all, f.sort); +const styles = playStyles(all); +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)); + +// 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 }) => ({ + label: `${status.data.kind} now`, + game, + note: status.data.note, + live: true, +})); +const recent = sortGames(all, "played").find((g) => g.data.lastPlayed && !table.some((t) => t.game.path === g.path)); +if (recent) table.push({ label: "Played recently", game: recent, note: `Last played ${formatDate(recent.data.lastPlayed)}`, live: false }); + +const sortHref = (key: string) => { + const p = new URLSearchParams(Astro.url.searchParams); + p.set("sort", key); + return `/library/shelf?${p}`; +}; +--- + + + +
    + +
    +
    +

    On the table

    + +
    + { + table.length > 0 ? ( + + ) : ( +

    Nothing on the table right now.

    + ) + } +
    +
    + +
    + + + + + + { + styles.length > 0 && ( + + {styles.map((p) => )} + + ) + } + + + + + + + +
    +
    +

    Showing {visible.size} {visible.size === 1 ? "game" : "games"}

    +
    + + ({ label: o.label, href: sortHref(o.key), active: o.key === f.sort, value: o.key }))} /> +
    +
    +
      + {games.map((g) =>
    + +
    +
    +
    + + + + diff --git a/services/site/src/pages/now.astro b/services/site/src/pages/now.astro new file mode 100644 index 0000000..4105e8b --- /dev/null +++ b/services/site/src/pages/now.astro @@ -0,0 +1,262 @@ +--- +/** + * Now: every status in the log, grouped (making, at the table, playing, + * reading, watching, up next), each an anchor at its slug, with the + * Everything / Now / Up next filter. + */ +import FilterGroup from "@gk/ui/components/FilterGroup.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import SectionHeading from "@gk/ui/components/SectionHeading.astro"; +import Star from "@gk/ui/components/Star.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import { seals } from "@gk/ui/components/seals.ts"; +import PageHero from "../components/PageHero.astro"; +import StatusRow from "../components/home/StatusRow.astro"; +import { groupStatuses, lastUpdated, viewStatus } from "../lib/home.ts"; +import { site } from "../lib/page.ts"; +import { formatDate } from "../lib/site.ts"; + +const s = await site(); +const text = s.pageText("/now"); +const groups = groupStatuses(s); +const updated = lastUpdated(s); +const lede = [text?.data.lede ?? "What I am making, reading, playing and watching right now.", updated && `Last updated ${formatDate(updated)}.`] + .filter(Boolean) + .join(" "); +const intro = s.render(text?.body); +const hasNext = groups.some((g) => g.key === "next"); +const wrapped = s + .all("status") + .filter((x) => /finish|wrapped|completed|done|caught up/i.test(x.data.kind)) + .sort((a, b) => b.data.date.localeCompare(a.data.date)) + .slice(0, 5) + .map((x) => viewStatus(s, x)); +--- + + + + + +
    +
    + {intro &&
    } + { + groups.length ? ( + groups.map((g) => ( +
    + + + {g.title} + +
      + {g.items.map((v) => ( + + ))} +
    +
    + )) + ) : ( +

    Nothing on the status log yet.

    + ) + } + {!hasNext && } +
    + + +
    + + + + + diff --git a/services/site/src/pages/resume.astro b/services/site/src/pages/resume.astro new file mode 100644 index 0000000..015042f --- /dev/null +++ b/services/site/src/pages/resume.astro @@ -0,0 +1,559 @@ +--- +/** + * The resume: at a glance with the PDF, highlights, skills and education in + * the sidebar, and the experience timeline. Every word comes from the Resume + * notes and the /resume Page text; the PDF is built from the same data. + */ +import Button from "@gk/ui/components/Button.astro"; +import Chip from "@gk/ui/components/Chip.astro"; +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 Star from "@gk/ui/components/Star.astro"; +import StarPath from "@gk/ui/components/StarPath.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import { site } from "../lib/page.ts"; +import { period, resumeData } from "../lib/resume.ts"; +import { formatDate } from "../lib/site.ts"; + +const s = await site(); +const r = resumeData(s); +const caseStudies = s.caseStudies(); +const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c) => c.toLowerCase() === item.toLowerCase()) ?? false; +--- + +
    +
    +
    RESUME AT A GLANCE
    +

    {r.name}

    +

    {[r.headline, r.location].filter(Boolean).join(" · ")}

    + {r.summary &&

    {r.summary}

    } +
    + +
    + + { + r.stats.length > 0 && ( +
    + {r.stats.map((st) => ( +
    + {st.value} + + {st.label} + {st.detail && {st.detail}} +
    + ))} +
    + ) + } + + {r.stats.length > 0 &&
    } + +
    + + +
    +
    +

    Experience

    + +
    +
    + + + { + r.roles.map((role, i) => ( +
    + {i === 0 && !role.data.end && } + +
    +

    {role.data.role}

    + {period(role)} +
    + {role.data.company}{role.data.location ? ` · ${role.data.location}` : ""} + {role.data.previousRoles?.length ? ( + Previously {role.data.previousRoles.join(" and ")} at {role.data.company} + ) : null} + {role.data.focus && {role.data.focus}} +
      + {role.data.bullets.map((b) => ( +
    • + + {b} +
    • + ))} +
    + {role.data.tech?.length ? ( +
    + {role.data.tech.map((t) => ( + {t} + ))} +
    + ) : null} +
    + )) + } +
    + {caseStudies.length > 0 && } +
    +
    +
    + + diff --git a/services/site/src/pages/resume.pdf.ts b/services/site/src/pages/resume.pdf.ts new file mode 100644 index 0000000..4d15e51 --- /dev/null +++ b/services/site/src/pages/resume.pdf.ts @@ -0,0 +1,25 @@ +/** The resume as a PDF, generated from the same notes as /resume and rebuilt only when they change. */ +import type { APIRoute } from "astro"; +import { site } from "../lib/page.ts"; +import { resumeData } from "../lib/resume.ts"; +import { renderResumePdf } from "../lib/resume-pdf.ts"; + +let cached: { key: string; pdf: Promise } | undefined; + +export const GET: APIRoute = async () => { + const r = resumeData(await site()); + const key = JSON.stringify(r); + if (cached?.key !== key) cached = { key, pdf: renderResumePdf(r) }; + const pdf = await cached.pdf.catch((err) => { + cached = undefined; + throw err; + }); + const file = `${r.name.replace(/[^A-Za-z0-9]+/g, "-")}-Resume.pdf`; + return new Response(new Uint8Array(pdf), { + headers: { + "content-type": "application/pdf", + "content-disposition": `inline; filename="${file}"`, + "cache-control": "public, max-age=300", + }, + }); +}; diff --git a/services/site/src/pages/rss.xml.ts b/services/site/src/pages/rss.xml.ts new file mode 100644 index 0000000..c259928 --- /dev/null +++ b/services/site/src/pages/rss.xml.ts @@ -0,0 +1,53 @@ +/** + * The RSS 2.0 feed: blog posts and case studies, newest first, with absolute + * links from the configured site address or the request's origin. + */ +import type { APIRoute } from "astro"; +import { xml } from "../lib/blog.ts"; +import { site } from "../lib/page.ts"; + +export const GET: APIRoute = async ({ site: configured, url }) => { + const s = await site(); + const origin = (configured ?? new URL(url.origin)).href.replace(/\/$/, ""); + const abs = (path: string) => new URL(path, `${origin}/`).href; + const settings = s.settings(); + const name = settings?.name ?? "Greg Kilmer"; + const posts = s.posts({ includeCaseStudies: true }).slice(0, 50); + const rfc822 = (iso: string) => new Date(`${iso}T12:00:00Z`).toUTCString(); + + const items = posts + .map((p) => { + const href = s.href(p); + if (!href) return ""; + const link = abs(href); + const category = p.data.postType === "case study" ? "Case study" : p.data.topic; + return [ + " ", + ` ${xml(p.title)}`, + ` ${xml(link)}`, + ` ${xml(link)}`, + ` ${rfc822(p.data.date)}`, + ` ${xml(category)}`, + p.data.summary ? ` ${xml(p.data.summary)}` : "", + " ", + ] + .filter(Boolean) + .join("\n"); + }) + .filter(Boolean) + .join("\n"); + + const body = ` + + + ${xml(name)} + ${xml(abs("/blog"))} + + ${xml(settings?.tagline ?? "What I am making, learning, playing and building.")} + en-us +${posts[0] ? ` ${rfc822(posts[0].data.date)}\n` : ""}${items} + + +`; + return new Response(body, { headers: { "content-type": "application/rss+xml; charset=utf-8", "cache-control": "public, max-age=600" } }); +}; diff --git a/services/site/src/pages/store/[slug].astro b/services/site/src/pages/store/[slug].astro new file mode 100644 index 0000000..19f9096 --- /dev/null +++ b/services/site/src/pages/store/[slug].astro @@ -0,0 +1,416 @@ +--- +/** + * One product line: gallery, summary and price, its slots or stock, the order + * button (or the closed or sold-out notice), specs, prices, options with + * swatches and stock, and its terms as ledger notes. + */ +import Button from "@gk/ui/components/Button.astro"; +import Chip from "@gk/ui/components/Chip.astro"; +import Notice from "@gk/ui/components/Notice.astro"; +import PageShell from "@gk/ui/components/PageShell.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 Breadcrumb from "../../components/store/Breadcrumb.astro"; +import LineStatus from "../../components/store/LineStatus.astro"; +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"; + +const s = await site(); +const slug = Astro.params.slug ?? ""; +const line = s.bySlug("product-line", slug); +if (!line) { + const to = s.redirect("product-line", slug); + if (to) return Astro.redirect(to, 301); + return Astro.rewrite("/404"); +} +const st = storeState(s); +const d = line.data; +const cat = st.category(d.category); +const ch = channelOf(line); +const orderable = canOrder(line, st); +const shop = shopLink(line); +const price = priceSummary(line); +const images = (d.images ?? []).map((im) => ({ ...im, src: mediaSrc(im.file) })); +const lead = images[0]; +const groups = optionGroups(line); +const body = s.render(line.body); +const terms = [...(d.terms ?? []), ...st.terms]; +--- + +
    + +
    + +
    + + +
    +

    {line.title}

    +

    {d.summary}

    + { + price && ( +
    + from + {price.from} + {[price.note, ...price.more].filter(Boolean).join(" · ")} +
    + ) + } + + { + ch === "here" && !cat.ordersOpen && ( + {cat.reopens ? `Reopens ${formatDate(cat.reopens)}.` : ""} + ) + } + { + ch === "here" && cat.ordersOpen && !hasCapacity(line) && ( + + {d.mode === "made to order" ? "Slots reopen as current orders finish." : ""} + + ) + } + {ch === "coming soon" && Coming soon} +
    + {orderable && } + {shop && } + +
    + {ch === "here" && cat.turnaround && {cat.turnaround}} +
    +
    + + {body &&
    } + + { + (!!d.specs?.length || d.priceTiers.length > 0) && ( +
    + {!!d.specs?.length && ( +
    + + Specs + +
    + {d.specs.map((sp) => ( +
    +
    {sp.label}
    +
    {sp.value}
    +
    + ))} +
    +
    + )} + {d.priceTiers.length > 0 && ( +
    + + Pricing + + + + + {d.priceTiers.map((t) => ( + + + + + + ))} + +
    Prices for {line.title}
    {t.band}{t.price}{t.note}
    +
    + )} +
    + ) + } + + { + groups.length > 0 && ( +
    + + Options + + {groups.map(([group, opts]) => ( +
    +

    {group}

    + +
    + ))} +
    + ) + } + + { + terms.length > 0 && ( +
    +
    + Before you order: requirements and policies +
    + +
    +
    +
    + ) + } + + + + + diff --git a/services/site/src/pages/store/index.astro b/services/site/src/pages/store/index.astro new file mode 100644 index 0000000..e9a1a4d --- /dev/null +++ b/services/site/src/pages/store/index.astro @@ -0,0 +1,356 @@ +--- +/** + * The Store: product lines grouped by category, each with its status, the + * store notice from Store settings, the in-stock colors, how ordering works + * and the store-wide terms. + */ +import Button from "@gk/ui/components/Button.astro"; +import Kicker from "@gk/ui/components/Kicker.astro"; +import Notice from "@gk/ui/components/Notice.astro"; +import SectionHeading from "@gk/ui/components/SectionHeading.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import LineCard from "../../components/store/LineCard.astro"; +import LineStatus from "../../components/store/LineStatus.astro"; +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"; + +const s = await site(); +const st = storeState(s); +const text = s.pageText("/store"); +const lede = text?.data.lede ?? st.settings?.capacityNote; + +const selling = st.lines.filter((l) => channelOf(l) === "here"); +const allClosed = st.closed.length > 0 && !st.anyOpen; +const reopenText = (c: { reopens?: string }) => (c.reopens ? `Reopens ${formatDate(c.reopens)}.` : ""); +const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround).filter((t): t is string => !!t))]; + +/** In-stock colors of lines sold here, one strip per line and option group. */ +const colorStrips = selling.flatMap((l) => + optionGroups(l) + .map(([group, opts]) => ({ line: l, group, opts: opts.filter((o) => o.inStock && o.hex) })) + .filter((x) => x.opts.length), +); +--- + + +
    +
    + { + allClosed ? ( + + {st.closed.map(reopenText).filter(Boolean).join(" ")} + + ) : ( + st.closed.map((c) => ( + + {reopenText(c)} + + )) + ) + } +

    A small workshop, not a factory

    + {lede &&

    {lede}

    } +
    + {st.anyOpen && } + +
    +
    + { + selling.length > 0 && ( +
    + {selling.map((l) => )} + {turnarounds.map((t) => {t})} +
    + ) + } +
    +
    + + { + st.groups.map(([cat, lines]) => { + const c = st.category(cat); + const id = `cat-${cat.toLowerCase()}`; + return ( +
    +
    + + {cat} + + {lines.some((l) => channelOf(l) === "here") && ( + {c.ordersOpen ? "Orders open" : `Orders closed${c.reopens ? ` · reopens ${formatDate(c.reopens)}` : ""}`} + )} +
    +
    + {lines.map((l) => ( + + ))} +
    +
    + ); + }) + } + {st.lines.length === 0 &&

    [PLACEHOLDER]

    } + + { + colorStrips.map(({ line, group, opts }) => { + const id = `colors-${line.slug}-${group.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; + return ( +
    +
    +

    {group} in stock

    + + {line.title} +
    + + All colors +
    + ); + }) + } + +
    + How ordering works +
      +
    1. + 1 + Send a request + Use the order form with your card list or deck box colors. No payment is taken through the site. +
    2. +
    3. + 2 + Confirm the details + I check the list and art, then confirm the total with you. +
    4. +
    5. + 3 + I make it + {turnarounds.map((t) => {t})} +
    6. +
    +
    + + { + st.terms.length > 0 && ( +
    +
    + + Before you order: requirements and policies +
    + +

    Each product line lists its own terms on its page.

    +
    +
    +
    + ) + } +
    + + diff --git a/services/site/src/pages/store/order.astro b/services/site/src/pages/store/order.astro new file mode 100644 index 0000000..4e7b33d --- /dev/null +++ b/services/site/src/pages/store/order.astro @@ -0,0 +1,176 @@ +--- +/** + * The order request form: lines that can take an order now, the chosen + * line's options, quantity, details, notes, contact and the terms. When no + * category takes orders, or every open line is full, a notice shows instead. + */ +import Button from "@gk/ui/components/Button.astro"; +import Notice from "@gk/ui/components/Notice.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Star from "@gk/ui/components/Star.astro"; +import TickScale from "@gk/ui/components/TickScale.astro"; +import Breadcrumb from "../../components/store/Breadcrumb.astro"; +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"; + +const s = await site(); +const st = storeState(s); +const selected = Astro.url.searchParams.get("line") ?? undefined; +const orderable = st.lines.filter((l) => canOrder(l, st)); +const selling = st.lines.filter((l) => channelOf(l) === "here"); +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)}.`); +--- + +
    + +

    Order request

    +

    Send a request and I will confirm the details and the total with you. No payment is taken through the site.

    +
    + +
    +
    + { + orderable.length > 0 ? ( + + ) : ( +
    + {st.anyOpen ? ( + Slots reopen as current orders finish. + ) : ( + {reopens.join(" ")} + )} +

    {st.settings?.capacityNote}

    +
    + + +
    +
    + ) + } +
    + + +
    +
    + + diff --git a/services/site/src/pages/tabletop.astro b/services/site/src/pages/tabletop.astro new file mode 100644 index 0000000..2df6a6d --- /dev/null +++ b/services/site/src/pages/tabletop.astro @@ -0,0 +1,448 @@ +--- +import FilterGroup from "@gk/ui/components/FilterGroup.astro"; +import Icon from "@gk/ui/components/Icon.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import PageHero from "../components/PageHero.astro"; +import NowStrip, { type NowItem } from "../components/world/NowStrip.astro"; +import WorldSection from "../components/world/WorldSection.astro"; +import type { Note } from "../lib/content.ts"; +import { plural, site } from "../lib/page.ts"; +import { slugify } from "@gk/schema"; +import { byOrder, deckUrl, makePhotos, sentence, stancePill, systemPill, systemStates } from "../lib/world.ts"; + +const s = await site(); +const text = s.pageText("/tabletop"); +const intro = s.render(text?.body); + +const stateRank = (st: string) => systemStates.indexOf(st as (typeof systemStates)[number]); +const systems = byOrder(s.all("tabletop-system")).sort((a, b) => stateRank(a.data.state) - stateRank(b.data.state)); +const states = systemStates.filter((st) => systems.some((x) => x.data.state === st)); + +const campaigns = [...s.all("campaign")].sort((a, b) => a.title.localeCompare(b.title)); +const atTable = campaigns.filter((c) => c.data.status === "active" || c.data.status === "playtest"); +const pastCampaigns = campaigns.filter((c) => !atTable.includes(c)); + +const games = byOrder(s.all("card-game")); +const allDecks = [...s.all("deck")].sort((a, b) => a.title.localeCompare(b.title)); +const isFav = (d: Note<"deck">) => !!d.data.favorite; +const decks = allDecks.some(isFav) ? allDecks.filter(isFav) : allDecks; +const gameOf = (d: Note<"deck">) => s.link(d.data.game); +const deckGroup = (d: Note<"deck">) => [shortGame(gameOf(d).title), d.data.format].filter(Boolean).join(" "); +const deckGroups = [...new Set(decks.map(deckGroup))]; + +/** "Magic: The Gathering" reads as MTG in kickers, as the design does. */ +function shortGame(title: string) { + return /^magic/i.test(title) ? "MTG" : title; +} + +/** A status that points at a deck makes it the deck of the moment. */ +const deckStatus = s.statuses().find((st) => s.byName(st.data.subject)?.type === "deck"); +const deckOfMoment = deckStatus ? (s.byName(deckStatus.data.subject) as Note<"deck">) : undefined; + +const nowItems: NowItem[] = atTable.map((c) => { + const system = c.data.system ? s.link(c.data.system) : null; + const label = c.data.status === "playtest" ? "Playtest" : [system?.title, c.data.platform && `on ${c.data.platform}`].filter(Boolean).join(" · "); + return { + id: c.slug, + label: label || "Campaign", + title: c.title, + note: [c.data.schedule, c.data.blurb].filter(Boolean).join(" · ") || undefined, + }; +}); +if (deckOfMoment) { + nowItems.push({ + label: ["Deck of the moment", deckGroup(deckOfMoment)].filter(Boolean).join(" · "), + title: deckOfMoment.title, + note: deckStatus?.data.note ?? deckOfMoment.data.take, + href: `#${deckOfMoment.slug}`, + }); +} + +const archidekt = s.settings()?.socials.find((x) => /archidekt/i.test(x.label)); +const shelfCount = s.all("board-game").length; +const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0]; +--- + + + + + + {nowItems.length > 0 && } + + {intro &&
    } + + { + systems.length > 0 && ( + + {states.length > 1 && ( + ({ label: sentence(st), value: st }))]} + /> + )} +
      + {systems.map((x) => { + const runs = campaigns.filter((c) => s.byName(c.data.system)?.path === x.path); + return ( +
    • +
      +

      {x.title}

      + {sentence(x.data.state)} +
      +

      {x.data.blurb}

      + {runs.length > 0 && ( +

      + {runs.map((c, i) => ( + <> + {i > 0 && " · "} + {c.title} + + ))} +

      + )} +
    • + ); + })} +
    + {pastCampaigns.length > 0 && ( +
    +

    Earlier campaigns

    +
      + {pastCampaigns.map((c) => ( +
    • + + {c.title} + {sentence(c.data.status)} + {c.data.blurb && {c.data.blurb}} +
    • + ))} +
    +
    + )} +
    + ) + } + + { + decks.length > 0 && ( + + {archidekt && ( + + All my Magic decks on Archidekt + + + )} + {deckGroups.length > 1 && ( + ({ label: g, value: g }))]} + /> + )} +
      + {decks.map((d) => { + const url = deckUrl(d); + const game = gameOf(d); + return ( +
    • + +
      + {[shortGame(game.title), d.data.format].filter(Boolean).join(" · ")} +
      +

      + {url ? ( + + {d.title} + + ) : ( + d.title + )} + {isFav(d) && } +

      + {deckOfMoment?.path === d.path && Playing now} +
      + {d.data.take &&

      {d.data.take}

      } + {url && ( + + {d.data.archidektId ? "On Archidekt" : "Deck list"} + + )} +
      +
    • + ); + })} +
    +
    + ) + } + + { + games.length > 0 && ( + +
      + {games.map((g) => { + const n = s.all("deck").filter((d) => s.byName(d.data.game)?.path === g.path).length; + return ( +
    • +
      +

      {g.title}

      + {g.data.stance} +
      + {g.data.blurb &&

      {g.data.blurb}

      } + {n > 0 &&

      {plural(n, "deck")} here

      } +
    • + ); + })} +
    +
    + ) + } + + + + + + + diff --git a/services/site/src/pages/work/[slug].astro b/services/site/src/pages/work/[slug].astro new file mode 100644 index 0000000..862741f --- /dev/null +++ b/services/site/src/pages/work/[slug].astro @@ -0,0 +1,35 @@ +--- +/** + * One case study: the post reading layout plus a facts strip (employer, + * role, period, key numbers, tech). A blog post's slug answers with a 301 to + * /blog/; an old slug follows its redirect. + */ +import FactsStrip from "../../components/blog/FactsStrip.astro"; +import PostLayout from "../../components/blog/PostLayout.astro"; +import { site } from "../../lib/page.ts"; + +const s = await site(); +const slug = Astro.params.slug ?? ""; +const post = s.bySlug("post", slug); +if (!post) { + const to = s.redirect("post", slug); + return to ? Astro.redirect(to, 301) : Astro.rewrite("/404"); +} +if (post.data.postType !== "case study") return Astro.redirect(`/blog/${post.slug}`, 301); + +const list = s.caseStudies(); +const pool = s.posts({ includeCaseStudies: true }); +const d = post.data; +--- + + + diff --git a/services/site/src/pages/work/index.astro b/services/site/src/pages/work/index.astro new file mode 100644 index 0000000..3e00cc8 --- /dev/null +++ b/services/site/src/pages/work/index.astro @@ -0,0 +1,174 @@ +--- +import Button from "@gk/ui/components/Button.astro"; +import FilterGroup from "@gk/ui/components/FilterGroup.astro"; +import Notice from "@gk/ui/components/Notice.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import PageHero from "../../components/PageHero.astro"; +import FeaturedStudy from "../../components/work/FeaturedStudy.astro"; +import StudyCard from "../../components/work/StudyCard.astro"; +import { site } from "../../lib/page.ts"; +import { coreTech, featuredStudy, groupLabels, otherStudies, workGroup, type WorkGroup } from "../../lib/work.ts"; + +const s = await site(); +const text = s.pageText("/work"); +const studies = s.caseStudies(); +const featured = featuredStudy(studies); +const rest = otherStudies(studies, featured); +const core = coreTech(s); +const groups = (["employer", "personal", "homelab"] as WorkGroup[]).filter((g) => studies.some((p) => workGroup(p) === g)); +const lede = text?.data.lede ?? "Employer work told as written case studies, plus the personal and homelab projects I build for myself."; +--- + + + + + + { + groups.length > 1 && ( + + ) + } + + { + studies.length === 0 && ( +
    + They are being written up now. +
    + ) + } + + {featured && } + + { + rest.length > 0 && ( +
    +
      + {rest.map((p) => ( + + ))} +
    + +
    + ) + } + +
    +
    +
    +
    +

    Have something like this in mind?

    + +
    + Jobs and freelance web work. +
    +
    + + +
    +
    +
    +
    + + + + diff --git a/services/site/src/pages/workshop/[slug].astro b/services/site/src/pages/workshop/[slug].astro new file mode 100644 index 0000000..425d0b9 --- /dev/null +++ b/services/site/src/pages/workshop/[slug].astro @@ -0,0 +1,419 @@ +--- +import Button from "@gk/ui/components/Button.astro"; +import Chip from "@gk/ui/components/Chip.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"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import { pageSeals } from "@gk/ui/components/seals.ts"; +import CtaBand from "../../components/world/CtaBand.astro"; +import NowStrip, { type NowItem } from "../../components/world/NowStrip.astro"; +import WorldSection from "../../components/world/WorldSection.astro"; +import type { Note } from "../../lib/content.ts"; +import { plural, site } from "../../lib/page.ts"; +import { formatDate, mediaSrc } from "../../lib/site.ts"; +import { buildLogs, byDateDesc, byOrder, craftPill, craftSeal, makePhotos, makesOf, sentence, toolsOf } from "../../lib/world.ts"; + +const s = await site(); +const slug = Astro.params.slug ?? ""; +const craft = s.bySlug("craft", slug); +if (!craft) { + const to = s.redirect("craft", slug); + if (to) return Astro.redirect(to, 301); + return Astro.rewrite("/404"); +} + +const seal = craftSeal(craft); +const artSeal = seal ?? pageSeals.workshop; +const station = craft.data.station ? s.link(craft.data.station) : null; +const tools = toolsOf(s, craft); +const makes = byDateDesc(makesOf(s, craft)); +const logs = buildLogs(s, craft); +const body = s.render(craft.body); +const others = byOrder(s.all("craft")).filter((c) => c.path !== craft.path); + +const related = new Set([craft.path, ...makes.map((m) => m.path)]); +const nowItems: NowItem[] = s + .statuses() + .filter((st) => { + const subject = s.byName(st.data.subject); + return subject && related.has(subject.path); + }) + .map((st) => { + const subject = s.link(st.data.subject!); + const img = subject.note?.type === "make" ? makePhotos(subject.note as Note<"make">)[0] : undefined; + return { + id: st.slug, + label: st.data.kind === "Making" ? "On the bench" : st.data.kind, + title: subject.title, + note: st.data.note, + href: subject.note?.type === "make" ? `#${subject.note.slug}` : undefined, + img, + }; + }); + +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); +--- + + +
    Workshop / Craft
    +

    {craft.title}

    + {craft.data.blurb &&

    {craft.data.blurb}

    } +
    + {craft.data.status} + + {meta} + { + station && ( + <> + {" · "} + {station.href ? {station.title} : station.title} + {" station"} + + ) + } + +
    +
    + + {body &&
    } + + {nowItems.length > 0 && } + + { + tools.length > 0 && ( + +
      + {tools.map(({ title, note }) => ( +
    • + {note && {note.data.kind === "other" ? "Tool" : sentence(note.data.kind)}} +

      {note ? {title} : title}

      + {note?.data.blurb && {note.data.blurb}} +
    • + ))} +
    +
    + ) + } + + 1 ? "Newest first." : undefined}> + { + makes.length > 0 ? ( +
      + {makes.map((m) => { + const photos = makePhotos(m); + const log = s.backlinks(m, { type: "post" })[0]; + return ( +
    • + + {photos.length > 1 && ( + + {photos.slice(1, 5).map((p, i) => ( + {`${m.title}, + ))} + + )} + +

      {m.title}

      + {m.data.blurb && {m.data.blurb}} + {(m.data.date || log) && ( + + {m.data.date && } + {log && Build log} + + )} +
      +
    • + ); + })} +
    + ) : ( +

    Nothing finished in this craft yet.

    + ) + } +
    + + { + logs.length > 0 && ( + +
      + {logs.map((p) => ( +
    • + +
      + + {p.data.topic} + + + {p.title} + {p.data.summary && {p.data.summary}} +
      +
    • + ))} +
    +
    + ) + } + + { + others.length > 0 && ( + + ) + } + + + + + + + diff --git a/services/site/src/pages/workshop/index.astro b/services/site/src/pages/workshop/index.astro new file mode 100644 index 0000000..91d1f0d --- /dev/null +++ b/services/site/src/pages/workshop/index.astro @@ -0,0 +1,481 @@ +--- +import Button from "@gk/ui/components/Button.astro"; +import GiltCorners from "@gk/ui/components/GiltCorners.astro"; +import PageShell from "@gk/ui/components/PageShell.astro"; +import Seal from "@gk/ui/components/Seal.astro"; +import Star from "@gk/ui/components/Star.astro"; +import StarChartArt from "@gk/ui/components/StarChartArt.astro"; +import StatusPill from "@gk/ui/components/StatusPill.astro"; +import TickRing from "@gk/ui/components/TickRing.astro"; +import PageHero from "../../components/PageHero.astro"; +import CtaBand from "../../components/world/CtaBand.astro"; +import NowStrip, { type NowItem } from "../../components/world/NowStrip.astro"; +import WorldSection from "../../components/world/WorldSection.astro"; +import type { Note } from "../../lib/content.ts"; +import { plural, site } from "../../lib/page.ts"; +import { buildLogs, byDateDesc, byOrder, craftPill, craftSeal, isSword, makePhotos, makesOf, statusesFor, toolsAt, toolsOf } from "../../lib/world.ts"; + +const s = await site(); +const text = s.pageText("/workshop"); +const intro = s.render(text?.body); + +const crafts = byOrder(s.all("craft")).map((craft) => { + const makes = byDateDesc(makesOf(s, craft)); + const photos = makes.flatMap((m) => makePhotos(m).slice(0, 1)).slice(0, 3); + return { craft, makes, photos, logs: buildLogs(s, craft).length, tools: toolsOf(s, craft).map((t) => t.title) }; +}); + +const stations = byOrder(s.all("station")).map((station) => ({ station, tools: toolsAt(s, station) })); +const loose = s.all("tool").filter((t) => !s.byName(t.data.station)); + +const allMakes = s.all("make"); +const swords = allMakes.filter(isSword); +const recent = byDateDesc(allMakes.filter((m) => !isSword(m))) + .sort((a, b) => Number(!!b.data.date) - Number(!!a.data.date) || Number(makePhotos(b).length > 0) - Number(makePhotos(a).length > 0)) + .slice(0, 4); + +const nowItems: NowItem[] = statusesFor(s, "/workshop").map((st) => { + const subject = st.data.subject ? s.link(st.data.subject) : null; + const img = subject?.note?.type === "make" ? makePhotos(subject.note as Note<"make">)[0] : undefined; + return { + id: st.slug, + label: st.data.kind === "Making" ? "On the bench" : st.data.kind, + title: subject?.title ?? st.title, + note: st.data.note, + href: subject?.href, + img, + }; +}); +--- + + + + + + {intro &&
    } + + {nowItems.length > 0 && } + + { + stations.length > 0 && ( + +
      + {stations.map(({ station, tools }) => ( +
    1. + + {station.data.order} +

      {station.title}

      +
      + {station.data.where} + {tools.length > 0 && ( +
        + {tools.map((t) => ( +
      • + + {t.title} +
      • + ))} +
      + )} + {station.data.goodFor && {station.data.goodFor}} +
    2. + ))} + {loose.length > 0 && ( +
    3. + + +

      Around the shop

      +
      +
        + {loose.map((t) => ( +
      • + + {t.title} +
      • + ))} +
      +
    4. + )} +
    +
    + ) + } + + + + + + { + recent.length > 0 && ( + +
      + {recent.map((m) => { + const found = s.byName(m.data.craft); + const craft = found?.type === "craft" ? (found as Note<"craft">) : undefined; + const log = s.backlinks(m, { type: "post" })[0]; + return ( +
    • + + + {m.title} + +
      + {craft && {craft.title}} +

      + {m.title} +

      + {m.data.blurb && {m.data.blurb}} + {log && ( + + Blog: {log.title} + + )} +
      +
    • + ); + })} +
    +
    + ) + } + + { + swords.length > 0 && ( + + ) + } + + + + + + + + diff --git a/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md b/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md index dc961a5..11a80d2 100644 --- a/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md +++ b/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md @@ -4,7 +4,8 @@ title: "{{title}}" publish: false # Only notes set to true appear on the site. created: "{{date:YYYY-MM-DD}}" # optional company: -role: +role: # The current or last title at this company. +previousRoles: [] # optional. Earlier titles at the same company, newest first, shown as Previously. start: end: # optional. Leave out while it is your current role. location: # optional