mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Phase 5: every page of the site, the resume PDF and RSS
Landing, About, Now, Resume (with a generated PDF), Work and case studies, Inquiries and the sent page, Library and the Shelf, Workshop and craft pages, Tabletop, Blog with topics and posts, RSS, the Store, product lines, the order form and the 404, all rendered from the content service's notes. Schema: experience previousRoles, skills stack core items, page stats, make tags and deck favorite. Icon names from notes map to brand icons in one place, and HTML comments are dropped when Markdown renders. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
@@ -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<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const s = await Promise.race([site(), new Promise<never>((_, 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"]] },
|
||||
];
|
||||
---
|
||||
<PageShell title="Off the star chart" family="notFound" description="This page isn't here.">
|
||||
<meta slot="head" name="robots" content="noindex" />
|
||||
<section class="lost" aria-labelledby="nf-title">
|
||||
<Seal name="zodiac" size={180} class="seal" />
|
||||
<span class="eyebrow">404 · Page not found</span>
|
||||
<h1 id="nf-title">Off the star chart</h1>
|
||||
<p class="lede">{lede}</p>
|
||||
<div class="actions">
|
||||
<Button href="/">Back home</Button>
|
||||
<Button href="/blog" variant="secondary">Blog</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="path" aria-hidden="true"><StarPath seed="not-found" strength="calm" centerStar={false} /></div>
|
||||
|
||||
<nav class="groups" aria-label="Elsewhere on the site">
|
||||
{
|
||||
groups.map((g) => (
|
||||
<section class="group" aria-labelledby={g.id}>
|
||||
<h2 id={g.id} class="kicker">
|
||||
<Star size={11} />
|
||||
{g.label}
|
||||
</h2>
|
||||
<TickScale width={150} height={10} count={30} class="ticks" />
|
||||
<ul>
|
||||
{g.links.map(([label, href]) => (
|
||||
<li>
|
||||
<a href={href}>{label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.lost {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.lost :global(.seal) {
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 22px rgba(43, 194, 183, 0.35));
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 6vw, 46px);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.path {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.groups {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 0 32px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 28px;
|
||||
}
|
||||
.group {
|
||||
flex: 1 1 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.kicker {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.group :global(.ticks) {
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
li a {
|
||||
display: block;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
transition: color var(--gk-ease);
|
||||
}
|
||||
li a:hover {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, string> = { github: "web", linkedin: "briefcase", archidekt: "proxy-cards" };
|
||||
---
|
||||
<PageShell title="About me" family="about" current="about" description={text?.data.lede ?? "The person behind the projects."}>
|
||||
<PageHero
|
||||
slot="hero"
|
||||
eyebrow="About"
|
||||
title="The person behind the projects"
|
||||
lede={text?.data.lede ?? "Two wings of the same house. This page is the bridge between them."}
|
||||
/>
|
||||
|
||||
<section aria-label="Introduction" class="intro">
|
||||
<div class="intro-text">
|
||||
{introHtml ? <blockquote class="iam" set:html={introHtml} /> : <blockquote class="iam"><p>[PLACEHOLDER]</p></blockquote>}
|
||||
</div>
|
||||
<div class="intro-portrait">
|
||||
<Portrait variant="panel" seal="astrolabe" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="story">
|
||||
<div class="arc">
|
||||
{
|
||||
sections.map((sec, i) => (
|
||||
<section aria-labelledby={`ab-${i}`} class="arc-section">
|
||||
<SectionHeading id={`ab-${i}`}>
|
||||
<Star size={14} />
|
||||
{sec.heading}
|
||||
</SectionHeading>
|
||||
<div class="prose" set:html={s.render(sec.body)} />
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside aria-label="Quick facts" class="facts">
|
||||
<div class="panel">
|
||||
<span class="panel-kicker"><Star size={11} /><span>Quick facts</span></span>
|
||||
<TickScale width={200} height={10} count={30} class="panel-ticks" />
|
||||
{
|
||||
facts.length > 0 && (
|
||||
<ul class="fact-list">
|
||||
{facts.map((f) => (
|
||||
<li>
|
||||
<Icon name={brandIcon(f.icon)} size={18} class="fact-icon" />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
{
|
||||
now.length > 0 && (
|
||||
<div class="right-now">
|
||||
<span class="rn-label">Right now</span>
|
||||
<ul class="rn-list">
|
||||
{now.map((v) => (
|
||||
<li>
|
||||
<span class="rn-kind">{v.kind}</span>
|
||||
<a class="rn-title" href={`/now#${v.slug}`}>{v.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p class="rn-note">
|
||||
Same status notes as the <a href="/now">Now page</a>, so both change together.
|
||||
{nowUpdated && <> Updated <time datetime={nowUpdated}>{formatDate(nowUpdated)}</time>.</>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
socials.length > 0 && (
|
||||
<div class="panel">
|
||||
<span class="panel-kicker"><Star size={11} /><span>Elsewhere</span></span>
|
||||
<TickScale width={200} height={10} count={30} class="panel-ticks" />
|
||||
<ul class="socials">
|
||||
{socials.map((x) => (
|
||||
<li>
|
||||
<a href={x.url} rel="me noopener" class="social">
|
||||
<Icon name={SOCIAL_ICONS[x.label.toLowerCase()] ?? "link"} size={18} />
|
||||
<span class="s-label">{x.label}</span>
|
||||
<span class="s-handle">{x.handle}</span>
|
||||
<Icon name="external" size={14} class="s-ext" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="ab-doors" class="wings">
|
||||
<SectionHeading id="ab-doors">
|
||||
<Star size={14} />
|
||||
Two wings of the same house
|
||||
</SectionHeading>
|
||||
<div class="wing-wrap">
|
||||
<div class="wing-seal" aria-hidden="true"><Seal name="astrolabe" size={360} /></div>
|
||||
<div class="wing-row">
|
||||
<div class="wing featured">
|
||||
<GiltCorners inset={6} size={30} />
|
||||
<span class="wing-eyebrow">For employers and clients</span>
|
||||
<h3 class="wing-title">Work with me</h3>
|
||||
<ul class="wing-links">
|
||||
<DoorLink size="small" href="/resume" title="Resume" />
|
||||
<DoorLink size="small" href="/work" title="Work" />
|
||||
<DoorLink size="small" href="/inquiries" title="Inquiries" />
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wing">
|
||||
<span class="wing-eyebrow">For friends and fellow nerds</span>
|
||||
<h3 class="wing-title">My world</h3>
|
||||
<ul class="wing-links">
|
||||
<DoorLink size="small" href="/library" title="Library" />
|
||||
<DoorLink size="small" href="/workshop" title="Workshop" />
|
||||
<DoorLink size="small" href="/tabletop" title="Tabletop" />
|
||||
<DoorLink size="small" href="/blog" title="Blog" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
padding-top: 40px;
|
||||
padding-bottom: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
.intro-text {
|
||||
flex: 3 1 520px;
|
||||
min-width: 0;
|
||||
}
|
||||
.iam {
|
||||
margin: 0;
|
||||
padding: 4px 0 4px 22px;
|
||||
border-left: 3px solid var(--gk-gold);
|
||||
font-size: 22px;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
}
|
||||
.iam :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
.iam :global(p + p) {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.intro-portrait {
|
||||
flex: 1 1 260px;
|
||||
max-width: 340px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.story {
|
||||
padding-top: 48px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.arc {
|
||||
flex: 3 1 560px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
.arc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.arc-section :global(h2),
|
||||
.wings :global(h2) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 26px;
|
||||
}
|
||||
.prose {
|
||||
font-size: 17px;
|
||||
line-height: 1.75;
|
||||
color: var(--gk-soft);
|
||||
max-width: 68ch;
|
||||
}
|
||||
.prose :global(p) {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.prose :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.facts {
|
||||
flex: 1 1 280px;
|
||||
max-width: 340px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
}
|
||||
.panel-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel :global(.panel-ticks) {
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
.fact-list,
|
||||
.rn-list,
|
||||
.socials {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.fact-list li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fact-list :global(.fact-icon) {
|
||||
flex: none;
|
||||
color: var(--gk-gold-bright);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.right-now {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
}
|
||||
.rn-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rn-list {
|
||||
gap: 8px;
|
||||
}
|
||||
.rn-list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.rn-kind {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-teal-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rn-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rn-title:hover {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.rn-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.social {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.social:hover {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.s-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.s-handle {
|
||||
color: var(--gk-muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.social :global(.s-ext) {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.wings {
|
||||
padding-top: 56px;
|
||||
padding-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.wing-wrap {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.wing-seal {
|
||||
position: absolute;
|
||||
inset: -70px 0;
|
||||
z-index: -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.12;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wing-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
.wing {
|
||||
flex: 1 1 380px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 26px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: rgba(19, 28, 43, 0.7);
|
||||
border: 1px solid var(--gk-lines);
|
||||
}
|
||||
.wing.featured {
|
||||
border-color: rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.wing-eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.wing-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.wing-links {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wing-links :global(.door-link) {
|
||||
color: var(--gk-text);
|
||||
}
|
||||
.wing-links :global(.door-link:hover) {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.facts {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.iam {
|
||||
font-size: 19px;
|
||||
}
|
||||
.wing {
|
||||
padding: 22px 20px;
|
||||
}
|
||||
.intro-portrait {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
/**
|
||||
* One blog post in the all-dark reading layout. A case study's slug answers
|
||||
* with a 301 to /work/<slug>; 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();
|
||||
---
|
||||
<PostLayout
|
||||
post={post}
|
||||
site={s}
|
||||
family="post"
|
||||
current="blog"
|
||||
crumbs={[{ label: "Blog", href: "/blog" }, { label: post.data.topic, href: topicHref(post.data.topic) }]}
|
||||
list={list}
|
||||
pool={list}
|
||||
/>
|
||||
@@ -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<string, string | number>) => {
|
||||
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" } };
|
||||
---
|
||||
<PageShell title="Blog" family="blog" current="blog" description={lede}>
|
||||
<Fragment slot="head">
|
||||
<link rel="alternate" type="application/rss+xml" title="Greg Kilmer" href="/rss.xml" />
|
||||
</Fragment>
|
||||
<Fragment slot="hero">
|
||||
<PageHero eyebrow="My world" title="Blog" lede={lede} />
|
||||
</Fragment>
|
||||
|
||||
{
|
||||
lead && (
|
||||
<section class="top" aria-label={lead.label === "Featured" ? "Featured post" : "Latest post"}>
|
||||
<LeadPost post={lead.post} label={lead.label} site={s} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<BlogLayout aside="Browse the blog">
|
||||
<section aria-labelledby="bl-posts" class="posts">
|
||||
<div class="bar">
|
||||
<StarHeading id="bl-posts">{heading}</StarHeading>
|
||||
{list.length > 0 && <span class="sort">Sort: <b>Newest</b></span>}
|
||||
</div>
|
||||
<FilterGroup
|
||||
label="Topics"
|
||||
items={[{ label: "All", href: "/blog", active: !filtered }, ...topics.map((t) => ({ label: t, href: topicHref(t) }))]}
|
||||
/>
|
||||
<form method="get" action="/blog" role="search" class="search">
|
||||
<SearchField label="Search posts" placeholder="Search posts" value={q} class="field" />
|
||||
{month && <input type="hidden" name="month" value={month} />}
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
<PostList posts={shown} site={s} {...empty} />
|
||||
{
|
||||
(more || page > 1) && (
|
||||
<nav class="pager" aria-label="Older and newer posts">
|
||||
{page > 1 && <Button variant="text" href={keep({ page: page - 1 })}>Newer posts</Button>}
|
||||
{more && <Button variant="secondary" href={keep({ page: page + 1 })}>Load older posts</Button>}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<Fragment slot="aside">
|
||||
<RightNowPanel site={s} />
|
||||
<TopicsPanel site={s} />
|
||||
{
|
||||
months.length > 0 && (
|
||||
<SidePanel title="Archive" seal="astrolabe">
|
||||
<RowList items={months.map((m) => ({ label: m.label, href: `/blog?month=${m.month}`, count: m.count, current: m.month === month }))} />
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
<a href="/rss.xml" class="gk-text rss"><Icon name="web" size={18} />RSS feed</a>
|
||||
</Fragment>
|
||||
</BlogLayout>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.top {
|
||||
padding-top: 32px;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.sort {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.sort b {
|
||||
color: var(--gk-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.search :global(.field) {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.rss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, { label: string; slug: string; count: number }>();
|
||||
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;
|
||||
---
|
||||
<PageShell title={`${topic} · Blog`} family="blog" current="blog" description={lede ?? `Posts about ${topic.toLowerCase()}.`}>
|
||||
<Fragment slot="hero">
|
||||
<CrumbHero crumbs={[{ label: "Blog", href: "/blog" }, { label: "Topic" }]} title={topic} lede={lede} />
|
||||
</Fragment>
|
||||
|
||||
<BlogLayout aside="More topics">
|
||||
<section aria-label={`Posts about ${topic}`} class="posts">
|
||||
{
|
||||
filters.length > 1 && (
|
||||
<FilterGroup
|
||||
label={`Filter ${topic} posts`}
|
||||
items={[
|
||||
{ label: `All ${topic.toLowerCase()}`, href: base, active: !active },
|
||||
...filters.map((f) => ({ label: f.label, href: `${base}?on=${f.slug}`, active: f === active })),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<PostList
|
||||
posts={shown}
|
||||
site={s}
|
||||
kicker={kicker}
|
||||
as="h2"
|
||||
emptyTitle={posts.length ? "No posts here" : `Nothing on ${topic.toLowerCase()} yet`}
|
||||
emptyText={posts.length ? "Try another filter." : `The first ${topic.toLowerCase()} post is on its way. Meanwhile, ${home.label} shows what is going on.`}
|
||||
emptyAction={posts.length ? { label: `All ${topic.toLowerCase()} posts`, href: base } : { label: `Visit ${home.label}`, href: home.href }}
|
||||
/>
|
||||
{
|
||||
(more || page > 1) && (
|
||||
<nav class="pager" aria-label="Older and newer posts">
|
||||
{page > 1 && <Button variant="text" href={pageHref(page - 1)}>Newer posts</Button>}
|
||||
{more && <Button variant="secondary" href={pageHref(page + 1)}>Load older posts</Button>}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<Fragment slot="aside">
|
||||
<a href={home.href} class="home">
|
||||
<Seal name="mini-transmutation" size={56} />
|
||||
<span class="hw">
|
||||
<span class="k">This topic lives in</span>
|
||||
<span class="t">{home.label}</span>
|
||||
<span class="b">{home.blurb}</span>
|
||||
</span>
|
||||
</a>
|
||||
<TopicsPanel site={s} title="All topics" current={topic} />
|
||||
</Fragment>
|
||||
</BlogLayout>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.home {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
text-decoration: none;
|
||||
color: var(--gk-text);
|
||||
transition: border-color var(--gk-ease);
|
||||
}
|
||||
.home:hover {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
.hw {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.k {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.t {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
.b {
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -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 ? `<p>${text.data.lede}</p>` : 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" });
|
||||
---
|
||||
<PageShell title="Greg Kilmer" family="landing" current="home">
|
||||
<p>{s.notes.length} notes, {s.statuses().length} statuses.</p>
|
||||
<PageShell title="Greg Kilmer" family="landing" current="home" description={settings?.tagline}>
|
||||
<Fragment slot="hero">
|
||||
<div class="intro">
|
||||
<div class="who">
|
||||
{
|
||||
eyebrow && (
|
||||
<Kicker class="eyebrow">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
{eyebrow}
|
||||
</Kicker>
|
||||
)
|
||||
}
|
||||
<h1 class="name">{name}</h1>
|
||||
{intro ? <div class="lede" set:html={intro} /> : <p class="lede">[PLACEHOLDER]</p>}
|
||||
</div>
|
||||
<div class="portrait-wrap">
|
||||
<Portrait variant="hero" seal="transmutation-star-paths" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doors">
|
||||
<section aria-labelledby="door-work" class="door work">
|
||||
<Seal name="transmutation" size={340} class="work-seal" />
|
||||
<GiltCorners inset={10} />
|
||||
<div class="door-head">
|
||||
<div class="door-eyebrow">For employers and clients</div>
|
||||
<div class="hd">
|
||||
<h2 id="door-work">Work with me</h2>
|
||||
<span class="underline" aria-hidden="true"></span>
|
||||
</div>
|
||||
{role?.data.focus && <p class="pitch">{role.data.focus}</p>}
|
||||
</div>
|
||||
<ul class="door-links">
|
||||
<DoorLink href="/resume" title="Resume" line="One page to scan, plus a PDF" />
|
||||
<DoorLink href="/work" title="Work" line={studies.length ? `Case studies: ${listFormat.format(studies)}` : "Case studies"} />
|
||||
<DoorLink href="/inquiries" title="Inquiries" line="Jobs, freelance web work, custom orders" />
|
||||
</ul>
|
||||
<div class="door-foot">
|
||||
<Button href="/inquiries">Start an inquiry</Button>
|
||||
{coreStack.length > 0 && <span class="stack">{coreStack.join(" · ")}</span>}
|
||||
{settings?.availability && <span class="stack">{settings.availability}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="door-world" class="door world">
|
||||
<StarChartArt seal="craft-dice" ratio="auto" src={photo?.src} alt={photo?.alt ?? ""} class="world-photo" />
|
||||
<div class="world-body">
|
||||
<span class="world-ticks" aria-hidden="true"><TickScale width={400} height={10} count={60} major={6} /></span>
|
||||
<Seal name="mini-transmutation" size={90} class="world-seal" />
|
||||
<Kicker>For friends and fellow nerds</Kicker>
|
||||
<div class="hd">
|
||||
<h2 id="door-world">My world</h2>
|
||||
<span class="underline" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="world-links">
|
||||
<Button variant="secondary" href="/library">Library</Button>
|
||||
<Button variant="secondary" href="/blog">Blog</Button>
|
||||
<Button variant="secondary" href="/blog/topic/making">Making</Button>
|
||||
<Button variant="secondary" href="/store">Store</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<section aria-labelledby="now-strip" class="now">
|
||||
<div class="now-head">
|
||||
<SectionHeading eyebrow="Status log" id="now-strip">Now</SectionHeading>
|
||||
<a href="/now" class="gk-text all">Everything I am into</a>
|
||||
</div>
|
||||
{
|
||||
statuses.length ? (
|
||||
<ul class="now-grid">
|
||||
{statuses.map((v) => (
|
||||
<NowCard view={v} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="empty">Nothing on the status log yet.</p>
|
||||
)
|
||||
}
|
||||
<BlogStrip site={s} />
|
||||
</section>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
padding-top: 0;
|
||||
margin-top: -8px;
|
||||
}
|
||||
.who {
|
||||
flex: 999 1 520px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.who :global(.eyebrow) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.who :global(.eyebrow .gk-kicker-text) {
|
||||
gap: 10px;
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--gk-teal-bright);
|
||||
flex: none;
|
||||
}
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: clamp(40px, 7vw, 56px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
border-left: 2px solid var(--gk-gold);
|
||||
font-size: 18px;
|
||||
line-height: 1.65;
|
||||
color: var(--gk-soft);
|
||||
max-width: 58ch;
|
||||
}
|
||||
.lede :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
.lede :global(p + p) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.portrait-wrap {
|
||||
flex: 1 1 260px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.doors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.door {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border-radius: var(--gk-radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.hd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.hd h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.underline {
|
||||
display: block;
|
||||
width: 180px;
|
||||
max-width: 60%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background:
|
||||
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
|
||||
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
|
||||
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
|
||||
}
|
||||
|
||||
.work {
|
||||
flex: 3 1 520px;
|
||||
padding: 34px;
|
||||
gap: 20px;
|
||||
color: var(--gk-starlight);
|
||||
background: radial-gradient(ellipse at 0% 0%, rgba(20, 163, 154, 0.22), transparent 55%), var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.7);
|
||||
box-shadow:
|
||||
inset 0 0 0 6px var(--gk-midnight),
|
||||
inset 0 0 0 7px rgba(201, 162, 74, 0.45);
|
||||
}
|
||||
.work :global(.work-seal) {
|
||||
position: absolute;
|
||||
right: -90px;
|
||||
top: -90px;
|
||||
opacity: 0.14;
|
||||
pointer-events: none;
|
||||
}
|
||||
.work > :not(:global(.work-seal), :global(.gk-corner)) {
|
||||
position: relative;
|
||||
}
|
||||
.door-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.door-eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.work h2 {
|
||||
font-size: clamp(28px, 4vw, 36px);
|
||||
}
|
||||
.pitch {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
max-width: 52ch;
|
||||
}
|
||||
.door-links {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid rgba(244, 242, 237, 0.35);
|
||||
}
|
||||
.door-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px 20px;
|
||||
}
|
||||
.stack {
|
||||
font-size: 13px;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
|
||||
.world {
|
||||
flex: 2 1 360px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.world :global(.world-photo) {
|
||||
height: 190px;
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid var(--gk-gold);
|
||||
}
|
||||
.world-body {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
flex: 1;
|
||||
padding: 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 90% at 100% 100%, rgba(106, 47, 224, 0.18), transparent 70%),
|
||||
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 32px),
|
||||
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 32px);
|
||||
}
|
||||
.world-ticks {
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
right: 26px;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.world :global(.world-seal) {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.world h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
.world-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.now {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
.now-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.now-head :global(h2) {
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.all {
|
||||
font-size: 14px;
|
||||
}
|
||||
.now-grid {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.work {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.world-body {
|
||||
padding: 22px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<InquiryType | "none", string> = {
|
||||
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");
|
||||
---
|
||||
<PageShell title="Inquiries" family="inquiries" current="inquiries" description="Ask about a role, freelance web work or a custom Patiun order.">
|
||||
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
|
||||
|
||||
<div class="split">
|
||||
<div class="intro">
|
||||
<Seal name="transmutation" size={140} class="seal" />
|
||||
<span class="eyebrow">Inquiries</span>
|
||||
<h1>Let's make something</h1>
|
||||
<span class="underline" aria-hidden="true"></span>
|
||||
<p class="lede">{lede}</p>
|
||||
{settings?.availability && <StatusPill class="avail">{settings.availability}</StatusPill>}
|
||||
<HeroHorizon />
|
||||
<ul class="facts">
|
||||
{
|
||||
quickFacts.map((f) => (
|
||||
<li>
|
||||
<Star size={12} />
|
||||
{f}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
socials.length > 0 && (
|
||||
<li>
|
||||
<Star size={12} />
|
||||
<span>
|
||||
Also on{" "}
|
||||
{socials.map((x, i) => (
|
||||
<>
|
||||
<a href={x.url} rel="me noopener">{x.label}</a>
|
||||
{i < socials.length - 2 ? ", " : i === socials.length - 2 ? " and " : ""}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form class="form" method="post" action="/api/inquiries" enctype="multipart/form-data" aria-label="Inquiry" data-inquiry>
|
||||
<div class="frame">
|
||||
<fieldset class="types">
|
||||
<legend>What's this about?</legend>
|
||||
<div class="tiles">
|
||||
{
|
||||
inquiryTypes.map((t) => (
|
||||
<label class="tile">
|
||||
<input type="radio" name="type" value={t.value} checked={t.value === selected} required class="sr" />
|
||||
<span class="tick" aria-hidden="true"><Star size={12} /></span>
|
||||
<span class="tile-name">{t.label}</span>
|
||||
<span class="tile-blurb">{t.blurb}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="pair">
|
||||
<Field label="Your name" name="name" placeholder="Jane Doe" autocomplete="name" required maxlength={120} />
|
||||
<Field label="Email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required maxlength={200} />
|
||||
</div>
|
||||
|
||||
<div class="q q-job" data-q="job">
|
||||
<div class="pair">
|
||||
<Field label="Company" name="job_company" placeholder="Where is the role?" autocomplete="organization" maxlength={160} />
|
||||
<Field label="Role" name="job_role" placeholder="e.g. Senior full-stack developer" maxlength={160} />
|
||||
</div>
|
||||
<ChoicePills legend="Kind of role" name="job_kind" options={["Full-time", "Contract", "Not sure yet"]} />
|
||||
<Field label="Link to the posting" name="job_link" type="url" placeholder="https://" hint="Optional." maxlength={500} />
|
||||
</div>
|
||||
|
||||
<div class="q q-freelance" data-q="freelance">
|
||||
<ChoicePills legend="What do you need?" name="freelance_need" options={["A new site", "An app", "An integration", "Something else"]} />
|
||||
<div class="pair">
|
||||
<Field label="Your current site" name="freelance_site" type="url" placeholder="https://" hint="Optional." maxlength={500} />
|
||||
<Field label="Timeline" name="freelance_timing" as="select" options={timing} />
|
||||
</div>
|
||||
<Field label="Date" name="freelance_date" type="date" class="when-date" />
|
||||
</div>
|
||||
|
||||
<div class="q q-patiun" data-q="patiun">
|
||||
<ChoicePills legend="What would you like made?" name="patiun_item" options={["Proxy cards", "Deck box", "Something else"]} />
|
||||
<div class="pair">
|
||||
<Field label="How many?" name="patiun_quantity" placeholder="e.g. 60 cards" maxlength={120} />
|
||||
<Field label="Need it by" name="patiun_timing" as="select" options={timing} />
|
||||
</div>
|
||||
<Field label="Date" name="patiun_date" type="date" class="when-date" />
|
||||
</div>
|
||||
|
||||
<Field label="Details" name="details" as="textarea" rows={5} required maxlength={5000} placeholder={placeholders[selected ?? "none"]} data-details />
|
||||
|
||||
<label class="upload">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4M7 9l5-5 5 5M5 20h14" /></svg>
|
||||
<span>Add a reference image or decklist <span class="browse">Browse</span><span class="file-name" data-file-name></span></span>
|
||||
<input type="file" name="attachment" accept="image/*,.pdf,.txt,.csv,.dek,.dec" class="sr" data-file />
|
||||
</label>
|
||||
|
||||
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark"></div>}
|
||||
|
||||
<div class="submit">
|
||||
<Button type="submit">Send inquiry</Button>
|
||||
{turnstileKey && <span class="note">Protected by Cloudflare Turnstile.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<script>
|
||||
const form = document.querySelector<HTMLFormElement>("[data-inquiry]");
|
||||
if (form) {
|
||||
const details = form.querySelector<HTMLTextAreaElement>("[data-details]");
|
||||
const hints: Record<string, string> = {
|
||||
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.",
|
||||
};
|
||||
// Only the chosen type's questions are sent.
|
||||
const sync = () => {
|
||||
const type = form.querySelector<HTMLInputElement>('input[name="type"]:checked')?.value;
|
||||
form.querySelectorAll<HTMLElement>("[data-q]").forEach((q) => {
|
||||
q.querySelectorAll<HTMLInputElement | HTMLSelectElement>("input, select").forEach((el) => (el.disabled = q.dataset.q !== type));
|
||||
});
|
||||
if (details && type && hints[type]) details.placeholder = hints[type];
|
||||
};
|
||||
form.querySelectorAll('input[name="type"]').forEach((r) => r.addEventListener("change", sync));
|
||||
sync();
|
||||
const file = form.querySelector<HTMLInputElement>("[data-file]");
|
||||
const fileName = form.querySelector<HTMLElement>("[data-file-name]");
|
||||
file?.addEventListener("change", () => {
|
||||
if (fileName) fileName.textContent = file.files?.[0] ? ` · ${file.files[0].name}` : "";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.split {
|
||||
padding: 24px 0 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 48px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.intro {
|
||||
flex: 1 1 340px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.intro :global(.seal) {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 6vw, 48px);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.underline {
|
||||
display: block;
|
||||
width: 180px;
|
||||
height: 2px;
|
||||
margin-top: -8px;
|
||||
border-radius: 2px;
|
||||
background:
|
||||
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent),
|
||||
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
|
||||
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--gk-gold);
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
color: var(--gk-soft);
|
||||
max-width: 44ch;
|
||||
}
|
||||
.intro :global(.avail) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.facts {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.facts li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form {
|
||||
position: relative;
|
||||
flex: 1 1 540px;
|
||||
min-width: 0;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
border-radius: var(--gk-radius);
|
||||
padding: 8px;
|
||||
}
|
||||
.frame {
|
||||
border: 1px solid rgba(138, 101, 22, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.form :global(.gk-label-text) {
|
||||
font-size: 14px;
|
||||
color: var(--gk-text);
|
||||
}
|
||||
.form :global(.gk-field) {
|
||||
font-size: 15px;
|
||||
}
|
||||
.types {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.types legend {
|
||||
padding: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 72px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--gk-radius);
|
||||
border: 1px solid var(--gk-lines);
|
||||
background: var(--gk-sky);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--gk-ease), background var(--gk-ease);
|
||||
}
|
||||
.tile:hover {
|
||||
border-color: var(--gk-gold-line);
|
||||
}
|
||||
.tile:has(:checked) {
|
||||
border-color: var(--gk-gold);
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.04) 0 1px, transparent 1px 14px),
|
||||
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.04) 0 1px, transparent 1px 14px),
|
||||
rgba(201, 162, 74, 0.1);
|
||||
}
|
||||
.tile:has(:focus-visible) {
|
||||
outline: 2px solid var(--gk-gold);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.tick {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
display: none;
|
||||
}
|
||||
.tile:has(:checked) .tick {
|
||||
display: inline-flex;
|
||||
}
|
||||
.tile-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gk-text);
|
||||
padding-right: 18px;
|
||||
}
|
||||
.tile:has(:checked) .tile-name {
|
||||
color: var(--gk-starlight);
|
||||
}
|
||||
.tile-blurb {
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.sr {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pair {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.pair > :global(*) {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Each type's questions show once that type is picked; no script needed. */
|
||||
.q {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.form:has(input[name="type"][value="job"]:checked) .q-job,
|
||||
.form:has(input[name="type"][value="freelance"]:checked) .q-freelance,
|
||||
.form:has(input[name="type"][value="patiun"]:checked) .q-patiun {
|
||||
display: flex;
|
||||
}
|
||||
.q :global(.when-date) {
|
||||
display: none;
|
||||
}
|
||||
.q:has(option[value="date"]:checked) :global(.when-date) {
|
||||
display: flex;
|
||||
}
|
||||
.upload {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-sky);
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--gk-ease);
|
||||
}
|
||||
.upload svg {
|
||||
flex: none;
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.upload:hover {
|
||||
border-color: var(--gk-lines-strong);
|
||||
}
|
||||
.upload:has(:focus-visible) {
|
||||
outline: 2px solid var(--gk-gold);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.browse {
|
||||
color: var(--gk-teal-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-name {
|
||||
color: var(--gk-text);
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.note {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.tiles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.frame {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.intro :global(.seal) {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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." },
|
||||
];
|
||||
---
|
||||
<PageShell title="Inquiry sent" family="inquiries" current="inquiries" description="Your inquiry is on its way.">
|
||||
<meta slot="head" name="robots" content="noindex" />
|
||||
<div class="split">
|
||||
<div class="intro">
|
||||
<Seal name="transmutation" size={132} class="seal" />
|
||||
<span class="eyebrow">Inquiry sent</span>
|
||||
<h1>Your note is on its way</h1>
|
||||
<p class="lede">Thanks{firstName ? `, ${firstName}` : ""}. I read every inquiry myself and reply within [PLACEHOLDER] days.</p>
|
||||
<ol class="steps" aria-label="What happens next">
|
||||
{
|
||||
steps.map((st, i) => (
|
||||
<li>
|
||||
<TickRing size={36}>{i + 1}</TickRing>
|
||||
<span class="step-text">
|
||||
<span class="step-title">{st.title}</span>
|
||||
<span class="step-detail">{st.text}</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="side">
|
||||
{
|
||||
rows.length > 0 && (
|
||||
<section class="sent" aria-labelledby="sent-heading">
|
||||
<h2 id="sent-heading" class="kicker">
|
||||
<Star size={11} />
|
||||
What you sent
|
||||
</h2>
|
||||
<TickScale width={150} height={10} count={30} class="ticks" />
|
||||
<dl>
|
||||
{rows.map((r) => (
|
||||
<div class="row">
|
||||
<dt>{r.label}</dt>
|
||||
<dd>{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
<nav class="wait" aria-labelledby="wait-heading">
|
||||
<h2 id="wait-heading" class="wait-kicker">While you wait</h2>
|
||||
<div class="wait-links">
|
||||
<Button href="/blog" variant="secondary">Blog</Button>
|
||||
<Button href="/workshop" variant="secondary">Workshop</Button>
|
||||
<Button href="/" variant="text">Back home</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.split {
|
||||
padding: 32px 0 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 48px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.intro {
|
||||
flex: 1 1 420px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.intro :global(.seal) {
|
||||
filter: drop-shadow(0 0 18px rgba(201, 162, 74, 0.35));
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 6vw, 46px);
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--gk-gold);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
color: var(--gk-soft);
|
||||
max-width: 46ch;
|
||||
}
|
||||
.steps {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.steps li {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 13px;
|
||||
}
|
||||
.step-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.step-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.step-detail {
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.side {
|
||||
flex: 1 1 440px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.sent {
|
||||
position: relative;
|
||||
padding: 28px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.kicker {
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sent :global(.ticks) {
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
dl {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 14px;
|
||||
}
|
||||
dt {
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.wait {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
border-radius: var(--gk-radius);
|
||||
border: 1px solid var(--gk-lines);
|
||||
}
|
||||
.wait-kicker {
|
||||
margin: 0;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.wait-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
---
|
||||
<PageShell title="Library" family="library" current="library" description="What Greg is reading, watching and playing now, what's next, and what stuck with him.">
|
||||
<Fragment slot="hero"><LibraryHero current="library" lede={lede} /></Fragment>
|
||||
|
||||
<Constellation items={constellation(items)} />
|
||||
<div class="divide"><Divider seed="library-list" /></div>
|
||||
|
||||
<FilterLayout action="/library" formId="lib-filters">
|
||||
<Fragment slot="rail">
|
||||
<SearchField label="Search the Library" placeholder="Search" value={f.q || undefined} />
|
||||
{f.type && <input type="hidden" name="type" value={f.type} />}
|
||||
<nav aria-label="Type" class="types">
|
||||
<span class="types-label" aria-hidden="true">Type</span>
|
||||
<a href={typeHref()} data-type="" aria-current={!f.type ? "true" : undefined}>Everything</a>
|
||||
{mediaGroups.map((g) => <a href={typeHref(g.key)} data-type={g.key} aria-current={f.type === g.key ? "true" : undefined}>{g.label}</a>)}
|
||||
</nav>
|
||||
<RailGroup legend="Show">
|
||||
{sections.map((sec) => <label class="check"><input type="checkbox" name="show" value={sec.key} checked={f.show.has(sec.key)} />{sec.label}</label>)}
|
||||
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
|
||||
<label class="check"><input type="checkbox" name="blog" value="1" checked={f.blog} />Has a blog post</label>
|
||||
</RailGroup>
|
||||
</Fragment>
|
||||
|
||||
<div class="lists">
|
||||
{
|
||||
groups.map((g) => (
|
||||
<section aria-labelledby={`lib-${g.key}`} class="group" data-group={g.key} hidden={g.visible === 0}>
|
||||
<ListHeading id={`lib-${g.key}`} dot={dots[g.key]}>{g.label}</ListHeading>
|
||||
<ul class="rows">
|
||||
{g.list.map((item) => <LibraryRow item={item} site={s} hidden={!shows(item)} />)}
|
||||
</ul>
|
||||
{g.key === "done" && g.list.length > FIRST && (
|
||||
<button type="button" class="gk-text more" data-more hidden>
|
||||
Show everything finished ({g.list.length})
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
))
|
||||
}
|
||||
<p class="empty" data-empty hidden={anyVisible}>Nothing in the Library matches these filters.</p>
|
||||
</div>
|
||||
</FilterLayout>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.divide {
|
||||
padding: 24px 0 4px;
|
||||
}
|
||||
.types {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.types-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.types a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border-left: 2px solid transparent;
|
||||
color: var(--gk-soft);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: border-color var(--gk-ease), color var(--gk-ease), background var(--gk-ease);
|
||||
}
|
||||
.types a:hover {
|
||||
color: var(--gk-text);
|
||||
border-left-color: var(--gk-gold-line);
|
||||
}
|
||||
.types a[aria-current] {
|
||||
border-left-color: var(--gk-gold);
|
||||
background: rgba(201, 162, 74, 0.08);
|
||||
color: var(--gk-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
}
|
||||
.group[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.more {
|
||||
align-self: flex-start;
|
||||
margin-top: 16px;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.types {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.types-label {
|
||||
flex-basis: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.types a {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius-pill);
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.types a:hover,
|
||||
.types a[aria-current] {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Live filtering: the same rules as the server, applied to the rendered rows.
|
||||
const form = document.getElementById("lib-filters") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const FIRST = 12;
|
||||
const rows = [...document.querySelectorAll<HTMLElement>(".rows > li[id]")];
|
||||
const groups = [...document.querySelectorAll<HTMLElement>("[data-group]")];
|
||||
const more = document.querySelector<HTMLButtonElement>("[data-more]");
|
||||
const empty = document.querySelector<HTMLElement>("[data-empty]");
|
||||
const typeLinks = [...form.querySelectorAll<HTMLAnchorElement>("[data-type]")];
|
||||
const text = new Map(rows.map((r) => [r, (r.textContent ?? "").toLowerCase()]));
|
||||
let type = new URLSearchParams(location.search).get("type") ?? "";
|
||||
let expanded = false;
|
||||
form.querySelector("[data-nojs]")?.remove();
|
||||
form.querySelector('input[name="type"]')?.remove();
|
||||
|
||||
const apply = () => {
|
||||
const data = new FormData(form);
|
||||
const show = new Set(data.getAll("show"));
|
||||
const fav = data.has("fav");
|
||||
const blog = data.has("blog");
|
||||
const q = String(data.get("q") ?? "").trim().toLowerCase();
|
||||
const narrowed = !!(type || fav || blog || q);
|
||||
let doneCount = 0;
|
||||
for (const r of rows) {
|
||||
const d = r.dataset;
|
||||
let ok = (!type || d.m === type) && show.has(d.s!) && (!fav || !!d.f) && (!blog || !!d.b) && (!q || text.get(r)!.includes(q));
|
||||
if (ok && d.s === "done" && !narrowed && !expanded && ++doneCount > FIRST) ok = false;
|
||||
r.hidden = !ok;
|
||||
}
|
||||
let any = false;
|
||||
for (const g of groups) {
|
||||
const visible = !!g.querySelector(".rows > li:not([hidden])");
|
||||
g.hidden = !visible;
|
||||
any ||= visible;
|
||||
}
|
||||
if (more) more.hidden = expanded || narrowed || !show.has("done") || doneCount <= FIRST;
|
||||
if (empty) empty.hidden = any;
|
||||
};
|
||||
|
||||
// Opening an anchor to a folded-away row unfolds the list first.
|
||||
const reveal = () => {
|
||||
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||
if (target && target.hidden && rows.includes(target)) {
|
||||
expanded = true;
|
||||
apply();
|
||||
target.scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("input", apply);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
});
|
||||
for (const a of typeLinks) {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
type = a.dataset.type ?? "";
|
||||
typeLinks.forEach((l) => (l === a ? l.setAttribute("aria-current", "true") : l.removeAttribute("aria-current")));
|
||||
history.replaceState(null, "", a.href);
|
||||
apply();
|
||||
});
|
||||
}
|
||||
more?.addEventListener("click", () => {
|
||||
expanded = true;
|
||||
apply();
|
||||
});
|
||||
addEventListener("hashchange", reveal);
|
||||
apply();
|
||||
reveal();
|
||||
}
|
||||
</script>
|
||||
@@ -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}`;
|
||||
};
|
||||
---
|
||||
<PageShell title="The Shelf" family="shelf" current="library" description="Greg's board game collection: what is on the table, how each one plays, and what hit the table last.">
|
||||
<Fragment slot="hero"><LibraryHero current="shelf" lede={lede} /></Fragment>
|
||||
|
||||
<section aria-labelledby="sh-table" class="top">
|
||||
<div class="count gk-featured">
|
||||
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
|
||||
<GiltCorners />
|
||||
<p class="count-text"><span class="num">{owned}</span><span>{owned === 1 ? "game" : "games"} on the shelf</span></p>
|
||||
</div>
|
||||
<div class="table">
|
||||
<div class="kick">
|
||||
<h2 id="sh-table"><Star size={14} />On the table</h2>
|
||||
<TickScale width={150} height={10} count={30} />
|
||||
</div>
|
||||
{
|
||||
table.length > 0 ? (
|
||||
<ul class="table-list">
|
||||
{table.map((t) => (
|
||||
<li>
|
||||
<a href={`#${t.game.slug}`} class:list={["gk-card", "table-card", { live: t.live }]}>
|
||||
<StarChartArt class="table-art" ratio="1 / 1" seal="tria-prima" src={mediaSrc(t.game.data.cover)} />
|
||||
<span class="table-body">
|
||||
<span class="table-label">{t.label}</span>
|
||||
<span class="table-title">{t.game.title}</span>
|
||||
{t.note && <span class="table-note">{t.note}</span>}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="quiet">Nothing on the table right now.</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divide"><Divider seed="shelf-list" /></div>
|
||||
|
||||
<FilterLayout action="/library/shelf" formId="shelf-filters">
|
||||
<Fragment slot="rail">
|
||||
<SearchField label="Search the Shelf" placeholder="Search games" value={f.q || undefined} />
|
||||
<input type="hidden" name="sort" value={f.sort} />
|
||||
{
|
||||
styles.length > 0 && (
|
||||
<RailGroup legend="How it plays">
|
||||
{styles.map((p) => <label class="check"><input type="checkbox" name="plays" value={p.toLowerCase()} checked={f.plays.has(p.toLowerCase())} />{p}</label>)}
|
||||
</RailGroup>
|
||||
)
|
||||
}
|
||||
<RailGroup legend="Show">
|
||||
<label class="check"><input type="checkbox" name="owned" value="1" checked={f.owned} />Owned</label>
|
||||
<label class="check"><input type="checkbox" name="want" value="1" checked={f.want} />Want to play</label>
|
||||
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
|
||||
</RailGroup>
|
||||
</Fragment>
|
||||
|
||||
<section aria-label="Games" class="games">
|
||||
<div class="bar">
|
||||
<p class="showing" aria-live="polite">Showing <strong data-count>{visible.size}</strong> <span data-noun>{visible.size === 1 ? "game" : "games"}</span></p>
|
||||
<div class="sort">
|
||||
<span class="sort-label" aria-hidden="true">Sort</span>
|
||||
<FilterGroup label="Sort" items={sorts.map((o) => ({ label: o.label, href: sortHref(o.key), active: o.key === f.sort, value: o.key }))} />
|
||||
</div>
|
||||
</div>
|
||||
<ul class="grid" data-grid>
|
||||
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug)} />)}
|
||||
</ul>
|
||||
<p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
|
||||
</section>
|
||||
</FilterLayout>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.count {
|
||||
position: relative;
|
||||
flex: 1 1 380px;
|
||||
min-width: 0;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.count :global(.count-art) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.count::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(14, 21, 34, 0.9), rgba(14, 21, 34, 0.2));
|
||||
}
|
||||
.count-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.num {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.count :global(.gk-corner) {
|
||||
z-index: 1;
|
||||
}
|
||||
.table {
|
||||
flex: 2 1 520px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.kick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kick h2 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.table-list li {
|
||||
flex: 1 1 280px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
.table-card {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.table-card.live {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
.table-card :global(.table-art) {
|
||||
flex: 0 0 56px;
|
||||
width: 56px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.table-card :global(.table-art .mat) {
|
||||
display: none;
|
||||
}
|
||||
.table-card :global(.table-art .seal) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.table-body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.table-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.table-title {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.table-note,
|
||||
.quiet {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.divide {
|
||||
padding: 24px 0 4px;
|
||||
}
|
||||
.games {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.showing {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.showing strong {
|
||||
color: var(--gk-text);
|
||||
}
|
||||
.sort {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sort-label {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Live filtering and sorting: the same rules as the server, on the rendered cards.
|
||||
const form = document.getElementById("shelf-filters") as HTMLFormElement | null;
|
||||
const grid = document.querySelector<HTMLElement>("[data-grid]");
|
||||
if (form && grid) {
|
||||
const cards = [...grid.children] as HTMLElement[];
|
||||
const text = new Map(cards.map((c) => [c, (c.querySelector(".body")?.textContent ?? "").toLowerCase()]));
|
||||
const title = (c: HTMLElement) => c.querySelector("h3")?.textContent?.trim() ?? "";
|
||||
const sortInput = form.querySelector<HTMLInputElement>('input[name="sort"]')!;
|
||||
const sortLinks = [...document.querySelectorAll<HTMLAnchorElement>(".sort .gk-filter")];
|
||||
const count = document.querySelector("[data-count]");
|
||||
const noun = document.querySelector("[data-noun]");
|
||||
const empty = document.querySelector<HTMLElement>("[data-empty]");
|
||||
form.querySelector("[data-nojs]")?.remove();
|
||||
|
||||
const desc = (k: "lp" | "c") => (a: HTMLElement, b: HTMLElement) => (b.dataset[k] ?? "").localeCompare(a.dataset[k] ?? "") || title(a).localeCompare(title(b));
|
||||
const orders: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
||||
played: desc("lp"),
|
||||
az: (a, b) => title(a).localeCompare(title(b)),
|
||||
added: desc("c"),
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const data = new FormData(form);
|
||||
const q = String(data.get("q") ?? "").trim().toLowerCase();
|
||||
const plays = data.getAll("plays").map(String);
|
||||
const owned = data.has("owned");
|
||||
const want = data.has("want");
|
||||
const fav = data.has("fav");
|
||||
let n = 0;
|
||||
for (const c of cards) {
|
||||
const d = c.dataset;
|
||||
const ok =
|
||||
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
|
||||
(!fav || !!d.f) &&
|
||||
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
|
||||
(!q || text.get(c)!.includes(q));
|
||||
c.hidden = !ok;
|
||||
if (ok) n++;
|
||||
}
|
||||
if (count) count.textContent = String(n);
|
||||
if (noun) noun.textContent = n === 1 ? "game" : "games";
|
||||
if (empty) empty.hidden = n > 0;
|
||||
};
|
||||
|
||||
const sort = (key: string) => {
|
||||
sortInput.value = key;
|
||||
grid.append(...[...cards].sort(orders[key] ?? orders.played));
|
||||
};
|
||||
|
||||
form.addEventListener("input", apply);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
});
|
||||
for (const a of sortLinks) {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const key = new URL(a.href).searchParams.get("sort") ?? "played";
|
||||
sortLinks.forEach((l) => (l === a ? l.setAttribute("aria-current", "page") : l.removeAttribute("aria-current")));
|
||||
history.replaceState(null, "", a.href);
|
||||
sort(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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));
|
||||
---
|
||||
<PageShell title="Now" family="now" current="now" description="What I am making, reading, playing and watching right now.">
|
||||
<PageHero slot="hero" eyebrow="My world" title="Now" lede={lede} />
|
||||
|
||||
<div class="filter-bar" data-now-filter hidden>
|
||||
<FilterGroup
|
||||
label="Show"
|
||||
items={[
|
||||
{ label: "Everything", value: "all", active: true },
|
||||
{ label: "Now", value: "now" },
|
||||
{ label: "Up next", value: "next" },
|
||||
]}
|
||||
/>
|
||||
<span class="order">Newest first · pinned on top</span>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="groups">
|
||||
{intro && <div class="intro" set:html={intro} />}
|
||||
{
|
||||
groups.length ? (
|
||||
groups.map((g) => (
|
||||
<section aria-labelledby={`n-${g.key}`} class="group" data-next={g.key === "next" ? "true" : "false"}>
|
||||
<SectionHeading id={`n-${g.key}`}>
|
||||
<Star size={12} />
|
||||
{g.title}
|
||||
</SectionHeading>
|
||||
<ul class="rows">
|
||||
{g.items.map((v) => (
|
||||
<StatusRow view={v} site={s} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<p class="empty">Nothing on the status log yet.</p>
|
||||
)
|
||||
}
|
||||
{!hasNext && <p class="empty" data-next-empty hidden>Nothing queued up next right now.</p>}
|
||||
</div>
|
||||
|
||||
<aside aria-label="About this page" class="aside">
|
||||
<div class="panel">
|
||||
<img src={seals.zodiac} alt="" aria-hidden="true" class="panel-seal" />
|
||||
<span class="panel-kicker"><Star size={11} /><span>How this works</span></span>
|
||||
<TickScale width={200} height={10} count={30} class="panel-ticks" />
|
||||
<p class="panel-text">
|
||||
Each line is a status note in my vault. When I or one of my agents update it, this page, the landing page and the
|
||||
matching page all change together.
|
||||
</p>
|
||||
</div>
|
||||
{
|
||||
wrapped.length > 0 && (
|
||||
<div class="panel">
|
||||
<img src={seals["seven-metals"]} alt="" aria-hidden="true" class="panel-seal" />
|
||||
<span class="panel-kicker"><Star size={11} /><span>Recently wrapped up</span></span>
|
||||
<TickScale width={200} height={10} count={30} class="panel-ticks" />
|
||||
<ul class="wrapped">
|
||||
{wrapped.map((v) => (
|
||||
<li>
|
||||
{v.href ? <a href={v.href}>{v.title}</a> : <span>{v.title}</span>}
|
||||
<time datetime={v.date}>{formatDate(v.date)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<script>
|
||||
const bar = document.querySelector<HTMLElement>("[data-now-filter]");
|
||||
if (bar) {
|
||||
bar.hidden = false;
|
||||
const buttons = [...bar.querySelectorAll<HTMLButtonElement>("button[data-value]")];
|
||||
const groups = [...document.querySelectorAll<HTMLElement>(".group[data-next]")];
|
||||
const apply = (value: string) => {
|
||||
for (const b of buttons) b.setAttribute("aria-pressed", String(b.dataset.value === value));
|
||||
for (const g of groups) {
|
||||
const next = g.dataset.next === "true";
|
||||
g.hidden = value === "now" ? next : value === "next" ? !next : false;
|
||||
}
|
||||
const empty = document.querySelector<HTMLElement>("[data-next-empty]");
|
||||
if (empty) empty.hidden = value !== "next";
|
||||
};
|
||||
for (const b of buttons) b.addEventListener("click", () => apply(b.dataset.value ?? "all"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filter-bar {
|
||||
padding-top: 28px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.filter-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.order {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.layout {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 48px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.groups {
|
||||
flex: 3 1 640px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
.intro {
|
||||
color: var(--gk-soft);
|
||||
max-width: 68ch;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.group[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.group :global(h2) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.empty {
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.aside {
|
||||
flex: 1 1 260px;
|
||||
max-width: 320px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
}
|
||||
.panel-seal {
|
||||
position: absolute;
|
||||
right: -26px;
|
||||
top: -26px;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
opacity: 0.14;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel :global(.panel-ticks) {
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
.panel-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wrapped {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.wrapped li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
}
|
||||
.wrapped a {
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wrapped a:hover {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.wrapped time {
|
||||
color: var(--gk-muted);
|
||||
flex: none;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.aside {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
---
|
||||
<PageShell title="Resume" family="resume" current="resume" description={`${r.name}: ${r.headline}. Experience, skills and a PDF to take with you.`}>
|
||||
<div slot="hero" class="hero">
|
||||
<div class="intro">
|
||||
<div class="plaque"><GiltCorners inset={4} size={30} />RESUME AT A GLANCE</div>
|
||||
<h1>{r.name}</h1>
|
||||
<p class="headline">{[r.headline, r.location].filter(Boolean).join(" · ")}</p>
|
||||
{r.summary && <p class="summary">{r.summary}</p>}
|
||||
</div>
|
||||
<aside class="take" aria-labelledby="take-h">
|
||||
<span class="take-star" aria-hidden="true"><TickRing size={36}><Star size={16} /></TickRing></span>
|
||||
<span class="take-head">
|
||||
<span id="take-h" class="take-label">TAKE IT WITH YOU</span>
|
||||
<TickScale width={120} count={24} />
|
||||
</span>
|
||||
<Button href="/resume.pdf" download="Greg-Kilmer-Resume.pdf">
|
||||
<Icon name="arrow-right" size={18} />Download PDF
|
||||
</Button>
|
||||
{r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>}
|
||||
<Button variant="secondary" href="/inquiries?type=job">Start an inquiry</Button>
|
||||
{r.availability && <span class="avail"><span class="dot" aria-hidden="true"></span>{r.availability}</span>}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{
|
||||
r.stats.length > 0 && (
|
||||
<section aria-label="Highlights" class="stats">
|
||||
{r.stats.map((st) => (
|
||||
<div class="stat">
|
||||
<span class="stat-value">{st.value}</span>
|
||||
<TickScale width={120} count={24} />
|
||||
<span class="stat-label">{st.label}</span>
|
||||
{st.detail && <span class="stat-detail">{st.detail}</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{r.stats.length > 0 && <div class="rule"><StarPath seed="resume" strength="calm" centerStar={false} /></div>}
|
||||
|
||||
<div class="columns">
|
||||
<aside class="side">
|
||||
{
|
||||
r.skills && (
|
||||
<section aria-labelledby="skills-h" class="panel">
|
||||
<Seal name="mini-astrolabe" size={116} class="panel-seal" />
|
||||
<div class="panel-head">
|
||||
<h2 id="skills-h">SKILLS</h2>
|
||||
<TickScale width={140} count={28} />
|
||||
</div>
|
||||
{r.skills.stack.map((g) => (
|
||||
<div class="group">
|
||||
<span class="group-label">{g.label}</span>
|
||||
<div class="chips">
|
||||
{g.items.map((item) => (
|
||||
<Chip core={isCore(g, item)}>{item}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{r.skills.stack.some((g) => g.core?.length) && <span class="hint">Teal outline marks the core stack.</span>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
{
|
||||
r.skills?.core?.length ? (
|
||||
<section aria-labelledby="strengths-h" class="panel">
|
||||
<div class="panel-head">
|
||||
<h2 id="strengths-h">STRENGTHS</h2>
|
||||
<TickScale width={140} count={28} />
|
||||
</div>
|
||||
<ul class="strengths">
|
||||
{r.skills.core.map((c) => (
|
||||
<li>
|
||||
<Star size={10} />
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
r.education?.degrees.length ? (
|
||||
<section aria-labelledby="edu-h" class="panel">
|
||||
<Seal name="mini-tome" size={116} class="panel-seal" />
|
||||
<div class="panel-head">
|
||||
<h2 id="edu-h">EDUCATION</h2>
|
||||
<TickScale width={140} count={28} />
|
||||
</div>
|
||||
{r.education.degrees.map((d) => (
|
||||
<div class="degree">
|
||||
<span class="degree-name">{d.credential}</span>
|
||||
<span class="muted">
|
||||
{d.institution} · {d.period}
|
||||
</span>
|
||||
{d.detail && <span class="muted small">{d.detail}</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
r.education?.certifications?.length ? (
|
||||
<section aria-labelledby="certs-h" class="panel">
|
||||
<Seal name="mini-layered" size={116} class="panel-seal" />
|
||||
<div class="panel-head">
|
||||
<h2 id="certs-h">CERTIFICATIONS</h2>
|
||||
<TickScale width={140} count={28} />
|
||||
</div>
|
||||
{r.education.certifications.map((c) => (
|
||||
<div class="cert">
|
||||
<span class="cert-name">{c.name}</span>
|
||||
<span class="muted">
|
||||
{c.issuer} · {c.year}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
</aside>
|
||||
|
||||
<section aria-labelledby="exp-h" class="experience">
|
||||
<div class="exp-head">
|
||||
<h2 id="exp-h">Experience</h2>
|
||||
<span class="gk-underline" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="grid" aria-hidden="true"></div>
|
||||
<div class="spine" aria-hidden="true"></div>
|
||||
{
|
||||
r.roles.map((role, i) => (
|
||||
<article id={role.slug} class:list={["role", { current: i === 0 && !role.data.end }]}>
|
||||
{i === 0 && !role.data.end && <GiltCorners inset={4} size={30} />}
|
||||
<span class="marker" aria-hidden="true">
|
||||
<Star size={i === 0 ? 14 : 11} tone={i === 0 ? "gold" : "outline"} />
|
||||
</span>
|
||||
<div class="role-top">
|
||||
<h3>{role.data.role}</h3>
|
||||
<span class="dates">{period(role)}</span>
|
||||
</div>
|
||||
<span class="company">{role.data.company}{role.data.location ? ` · ${role.data.location}` : ""}</span>
|
||||
{role.data.previousRoles?.length ? (
|
||||
<span class="previously">Previously {role.data.previousRoles.join(" and ")} at {role.data.company}</span>
|
||||
) : null}
|
||||
{role.data.focus && <span class="focus">{role.data.focus}</span>}
|
||||
<ul>
|
||||
{role.data.bullets.map((b) => (
|
||||
<li>
|
||||
<Star size={9} />
|
||||
<span>{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{role.data.tech?.length ? (
|
||||
<div class="chips">
|
||||
{role.data.tech.map((t) => (
|
||||
<Chip>{t}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{caseStudies.length > 0 && <Button variant="text" href="/work" arrow>See the case studies on the Work page</Button>}
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.intro {
|
||||
flex: 1 1 520px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.plaque {
|
||||
align-self: flex-start;
|
||||
position: relative;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
border-radius: 10px;
|
||||
padding: 17px 60px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 6vw, 48px);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.headline {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.summary {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--gk-gold);
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
color: var(--gk-soft);
|
||||
max-width: 62ch;
|
||||
}
|
||||
.take {
|
||||
flex: 0 1 310px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.6);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
inset 0 0 0 5px var(--gk-midnight),
|
||||
inset 0 0 0 6px rgba(201, 162, 74, 0.3);
|
||||
padding: 32px 26px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.take-star {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -18px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: var(--gk-midnight);
|
||||
display: flex;
|
||||
}
|
||||
.take-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.take-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.pdf-date {
|
||||
margin-top: -4px;
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.avail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed rgba(244, 242, 237, 0.16);
|
||||
font-size: 13px;
|
||||
color: var(--gk-soft);
|
||||
text-align: center;
|
||||
}
|
||||
.dot {
|
||||
flex: 0 0 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--gk-teal-bright);
|
||||
box-shadow: 0 0 8px rgba(43, 194, 183, 0.7);
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 12px 0 8px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-top: 2px solid var(--gk-gold);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 44px;
|
||||
color: var(--gk-gold-bright);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-detail {
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rule {
|
||||
padding: 28px 0 4px;
|
||||
}
|
||||
.columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 48px;
|
||||
align-items: flex-start;
|
||||
padding: 32px 0 24px;
|
||||
}
|
||||
.side {
|
||||
flex: 1 1 300px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.panel :global(.panel-seal) {
|
||||
position: absolute;
|
||||
right: -30px;
|
||||
top: -30px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.small {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.strengths {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.strengths li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.degree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.degree-name {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cert {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cert-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Once the columns stack, the experience leads and the sidebar follows. */
|
||||
@media (max-width: 960px) {
|
||||
.experience {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
.experience {
|
||||
flex: 2 1 560px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.exp-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.exp-head h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.timeline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding-left: 36px;
|
||||
}
|
||||
.grid {
|
||||
position: absolute;
|
||||
inset: -24px -24px -24px 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.045) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.045) 0 1px, transparent 1px 40px);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 40% 50%, #000 40%, transparent 80%);
|
||||
mask-image: radial-gradient(ellipse at 40% 50%, #000 40%, transparent 80%);
|
||||
}
|
||||
.spine {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(to bottom, var(--gk-gold) 0 1px, transparent 1px 4px);
|
||||
}
|
||||
.role {
|
||||
position: relative;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.role.current {
|
||||
border-color: rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: -26.5px;
|
||||
top: 28px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
background: var(--gk-sky);
|
||||
padding: 3px;
|
||||
}
|
||||
.role-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.role h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.role.current h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.dates {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.company {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.previously,
|
||||
.focus {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.role ul {
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.role li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.role li :global(svg) {
|
||||
margin-top: 7px;
|
||||
}
|
||||
</style>
|
||||
@@ -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<Buffer> } | 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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 [
|
||||
" <item>",
|
||||
` <title>${xml(p.title)}</title>`,
|
||||
` <link>${xml(link)}</link>`,
|
||||
` <guid isPermaLink="true">${xml(link)}</guid>`,
|
||||
` <pubDate>${rfc822(p.data.date)}</pubDate>`,
|
||||
` <category>${xml(category)}</category>`,
|
||||
p.data.summary ? ` <description>${xml(p.data.summary)}</description>` : "",
|
||||
" </item>",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${xml(name)}</title>
|
||||
<link>${xml(abs("/blog"))}</link>
|
||||
<atom:link href="${xml(abs("/rss.xml"))}" rel="self" type="application/rss+xml" />
|
||||
<description>${xml(settings?.tagline ?? "What I am making, learning, playing and building.")}</description>
|
||||
<language>en-us</language>
|
||||
${posts[0] ? ` <lastBuildDate>${rfc822(posts[0].data.date)}</lastBuildDate>\n` : ""}${items}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
return new Response(body, { headers: { "content-type": "application/rss+xml; charset=utf-8", "cache-control": "public, max-age=600" } });
|
||||
};
|
||||
@@ -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];
|
||||
---
|
||||
<PageShell title={line.title} family="store" current="store" description={d.summary}>
|
||||
<div class="crumb-row">
|
||||
<Breadcrumb here={d.category} />
|
||||
</div>
|
||||
|
||||
<section class="top" aria-label={line.title}>
|
||||
<div class="gallery">
|
||||
<figure class="lead">
|
||||
<StarChartArt seal="craft-dice" ratio="3 / 2" src={lead?.src} alt={lead?.alt ?? ""} class="lead-art" />
|
||||
{lead?.label && <figcaption>{lead.label}</figcaption>}
|
||||
</figure>
|
||||
{
|
||||
images.length > 1 && (
|
||||
<ul class="thumbs" aria-label="More photos">
|
||||
{images.map((im, i) => (
|
||||
<li>
|
||||
<a href={im.src} class:list={["thumb", { on: i === 0 }]} data-src={im.src} data-alt={im.alt} data-label={im.label} aria-current={i === 0 ? "true" : undefined}>
|
||||
<img src={im.src} alt={im.alt} loading="lazy" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h1>{line.title}</h1>
|
||||
<p class="summary">{d.summary}</p>
|
||||
{
|
||||
price && (
|
||||
<div class="price">
|
||||
<span class="from">from</span>
|
||||
<span class="amount">{price.from}</span>
|
||||
<span class="more">{[price.note, ...price.more].filter(Boolean).join(" · ")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<LineStatus line={line} label={d.mode === "made to order" && ch === "here" ? "Order slots" : "Availability"} />
|
||||
{
|
||||
ch === "here" && !cat.ordersOpen && (
|
||||
<Notice title={`${d.category} orders currently closed.`}>{cat.reopens ? `Reopens ${formatDate(cat.reopens)}.` : ""}</Notice>
|
||||
)
|
||||
}
|
||||
{
|
||||
ch === "here" && cat.ordersOpen && !hasCapacity(line) && (
|
||||
<Notice title={d.mode === "made to order" ? "All order slots are taken." : "Sold out."}>
|
||||
{d.mode === "made to order" ? "Slots reopen as current orders finish." : ""}
|
||||
</Notice>
|
||||
)
|
||||
}
|
||||
{ch === "coming soon" && <Chip>Coming soon</Chip>}
|
||||
<div class="actions">
|
||||
{orderable && <Button href={`/store/order?line=${encodeURIComponent(line.slug)}`}>Order request</Button>}
|
||||
{shop && <Button href={shop.href} rel="noopener" arrow>{shop.label}</Button>}
|
||||
<Button variant={orderable || shop ? "secondary" : "primary"} href="/inquiries">Something custom? Start an inquiry</Button>
|
||||
</div>
|
||||
{ch === "here" && cat.turnaround && <span class="turn">{cat.turnaround}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{body && <section class="gk-prose body" aria-label="About this line" set:html={body} />}
|
||||
|
||||
{
|
||||
(!!d.specs?.length || d.priceTiers.length > 0) && (
|
||||
<div class="tables">
|
||||
{!!d.specs?.length && (
|
||||
<section class="col" aria-labelledby="pl-specs">
|
||||
<SectionHeading id="pl-specs">
|
||||
<Star size={14} /> Specs
|
||||
</SectionHeading>
|
||||
<dl class="specs">
|
||||
{d.specs.map((sp) => (
|
||||
<div>
|
||||
<dt>{sp.label}</dt>
|
||||
<dd>{sp.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
{d.priceTiers.length > 0 && (
|
||||
<section class="col" aria-labelledby="pl-price">
|
||||
<SectionHeading id="pl-price">
|
||||
<Star size={14} /> Pricing
|
||||
</SectionHeading>
|
||||
<table class="prices">
|
||||
<caption class="gk-visually-hidden">Prices for {line.title}</caption>
|
||||
<tbody>
|
||||
{d.priceTiers.map((t) => (
|
||||
<tr>
|
||||
<th scope="row">{t.band}</th>
|
||||
<td class="p">{t.price}</td>
|
||||
<td class="n">{t.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
groups.length > 0 && (
|
||||
<section class="options" id="options" aria-labelledby="pl-options">
|
||||
<SectionHeading id="pl-options">
|
||||
<Star size={14} /> Options
|
||||
</SectionHeading>
|
||||
{groups.map(([group, opts]) => (
|
||||
<div class="group">
|
||||
<h3>{group}</h3>
|
||||
<Swatches options={opts} names />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
terms.length > 0 && (
|
||||
<section class="policies" aria-labelledby="pl-terms">
|
||||
<details>
|
||||
<summary id="pl-terms">Before you order: requirements and policies</summary>
|
||||
<div class="pol-body">
|
||||
<Terms terms={terms} />
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</PageShell>
|
||||
|
||||
<script>
|
||||
// Thumbnails swap the lead photo; without JS they open the full image.
|
||||
for (const a of document.querySelectorAll<HTMLAnchorElement>(".thumb")) {
|
||||
a.addEventListener("click", (e) => {
|
||||
const fig = document.querySelector(".lead");
|
||||
const img = fig?.querySelector<HTMLImageElement>("img.photo");
|
||||
if (!fig || !img) return;
|
||||
e.preventDefault();
|
||||
img.src = a.dataset.src ?? img.src;
|
||||
img.alt = a.dataset.alt ?? "";
|
||||
const cap = fig.querySelector("figcaption");
|
||||
if (cap) cap.textContent = a.dataset.label ?? "";
|
||||
for (const t of document.querySelectorAll(".thumb")) {
|
||||
t.classList.toggle("on", t === a);
|
||||
if (t === a) t.setAttribute("aria-current", "true");
|
||||
else t.removeAttribute("aria-current");
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.crumb-row {
|
||||
padding: 28px 0 8px;
|
||||
}
|
||||
.top {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.gallery {
|
||||
flex: 3 1 560px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.lead {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.lead :global(.lead-art) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
figcaption {
|
||||
padding: 10px 6px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.thumbs {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.thumb {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--gk-lines);
|
||||
overflow: hidden;
|
||||
}
|
||||
.thumb.on,
|
||||
.thumb:hover {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
.thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.info {
|
||||
flex: 2 1 380px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 5vw, 42px);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.price {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
.from,
|
||||
.more {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.amount {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 34px;
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.info :global(.gk-chip) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.turn {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.body {
|
||||
padding: 28px 0 0;
|
||||
max-width: 72ch;
|
||||
}
|
||||
.tables {
|
||||
margin-top: 28px;
|
||||
padding: 24px 0 8px;
|
||||
border-top: 1px solid rgba(201, 162, 74, 0.35);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.col {
|
||||
flex: 1 1 420px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.col :global(h2),
|
||||
.options :global(h2) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 26px;
|
||||
}
|
||||
.specs {
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.specs div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
}
|
||||
dt {
|
||||
flex: 0 0 160px;
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
dd {
|
||||
flex: 1 1 240px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prices {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.prices tr {
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
}
|
||||
.prices th {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
}
|
||||
.prices .p {
|
||||
padding: 12px 0;
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--gk-gold-bright);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prices .n {
|
||||
padding: 12px 0 12px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.options {
|
||||
padding: 32px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.group h3 {
|
||||
margin: 0;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.policies {
|
||||
padding: 28px 0 64px;
|
||||
}
|
||||
details {
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
}
|
||||
summary {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -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),
|
||||
);
|
||||
---
|
||||
<PageShell title="Store" family="store" current="store" description={st.settings?.capacityNote ?? "Things I make, in small batches."}>
|
||||
<Fragment slot="hero">
|
||||
<div class="hero">
|
||||
<div class="hero-main">
|
||||
{
|
||||
allClosed ? (
|
||||
<Notice title="Orders currently closed." class="hero-note">
|
||||
{st.closed.map(reopenText).filter(Boolean).join(" ")}
|
||||
</Notice>
|
||||
) : (
|
||||
st.closed.map((c) => (
|
||||
<Notice title={`${c.name} orders currently closed.`} class="hero-note">
|
||||
{reopenText(c)}
|
||||
</Notice>
|
||||
))
|
||||
)
|
||||
}
|
||||
<h1>A small workshop, not a factory</h1>
|
||||
{lede && <p class="lede">{lede}</p>}
|
||||
<div class="actions">
|
||||
{st.anyOpen && <Button href="/store/order">Start an order request</Button>}
|
||||
<Button variant={st.anyOpen ? "secondary" : "primary"} href="/inquiries">Something custom? Start an inquiry</Button>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
selling.length > 0 && (
|
||||
<div class="hero-meters" aria-label="Order slots">
|
||||
{selling.map((l) => <LineStatus line={l} class="meter" />)}
|
||||
{turnarounds.map((t) => <span class="turn">{t}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
{
|
||||
st.groups.map(([cat, lines]) => {
|
||||
const c = st.category(cat);
|
||||
const id = `cat-${cat.toLowerCase()}`;
|
||||
return (
|
||||
<section class="cat" aria-labelledby={id}>
|
||||
<div class="cat-head">
|
||||
<Kicker as="div" star>
|
||||
<span id={id}>{cat}</span>
|
||||
</Kicker>
|
||||
{lines.some((l) => channelOf(l) === "here") && (
|
||||
<span class="cat-state">{c.ordersOpen ? "Orders open" : `Orders closed${c.reopens ? ` · reopens ${formatDate(c.reopens)}` : ""}`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="cards">
|
||||
{lines.map((l) => (
|
||||
<LineCard line={l} open={c.ordersOpen} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
}
|
||||
{st.lines.length === 0 && <p class="empty">[PLACEHOLDER]</p>}
|
||||
|
||||
{
|
||||
colorStrips.map(({ line, group, opts }) => {
|
||||
const id = `colors-${line.slug}-${group.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
return (
|
||||
<section class="colors" aria-labelledby={id}>
|
||||
<div class="colors-head">
|
||||
<h2 id={id}>{group} in stock</h2>
|
||||
<TickScale width={140} height={10} count={22} />
|
||||
<span class="colors-for">{line.title}</span>
|
||||
</div>
|
||||
<Swatches options={opts} />
|
||||
<a href={`/store/${line.slug}#options`} class="gk-text all">All colors</a>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<section class="how" aria-labelledby="st-how">
|
||||
<SectionHeading id="st-how"><Star size={14} /> How ordering works</SectionHeading>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<TickRing size={44}>1</TickRing>
|
||||
<span class="step-title">Send a request</span>
|
||||
<span class="step-text">Use the order form with your card list or deck box colors. No payment is taken through the site.</span>
|
||||
</li>
|
||||
<li>
|
||||
<TickRing size={44}>2</TickRing>
|
||||
<span class="step-title">Confirm the details</span>
|
||||
<span class="step-text">I check the list and art, then confirm the total with you.</span>
|
||||
</li>
|
||||
<li>
|
||||
<TickRing size={44}>3</TickRing>
|
||||
<span class="step-title">I make it</span>
|
||||
{turnarounds.map((t) => <span class="step-text">{t}</span>)}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{
|
||||
st.terms.length > 0 && (
|
||||
<section class="policies" aria-labelledby="st-pol">
|
||||
<details open>
|
||||
<Seal name="craft-dice" size={220} class="pol-seal" />
|
||||
<summary id="st-pol">Before you order: requirements and policies</summary>
|
||||
<div class="pol-body">
|
||||
<Terms terms={st.terms} />
|
||||
<p class="pol-more">Each product line lists its own terms on its page.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-main {
|
||||
flex: 2 1 520px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.hero-main :global(.hero-note) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 6vw, 48px);
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--gk-gold);
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
color: var(--gk-soft);
|
||||
max-width: 58ch;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hero-meters {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.hero-meters :global(.meter) {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.turn {
|
||||
flex: 1 1 100%;
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
|
||||
.cat {
|
||||
padding: 32px 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.cat-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 8px 24px;
|
||||
}
|
||||
.cat-head :global(.gk-kicker-text) {
|
||||
font-size: 13px;
|
||||
}
|
||||
.cat-state {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
.empty {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.colors {
|
||||
margin: 8px 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px 28px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 24px),
|
||||
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 24px),
|
||||
var(--gk-sky);
|
||||
}
|
||||
.colors-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.colors h2 {
|
||||
margin: 0;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.colors-for {
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.colors .all {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.how {
|
||||
padding: 32px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.how :global(h2) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 26px;
|
||||
}
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.steps li {
|
||||
flex: 1 1 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 22px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
}
|
||||
.step-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.step-text {
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.policies {
|
||||
padding: 16px 0 64px;
|
||||
}
|
||||
details {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
padding: 22px 26px;
|
||||
}
|
||||
details :global(.pol-seal) {
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
bottom: -60px;
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.pol-body {
|
||||
margin-top: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.pol-more {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
details {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
.colors {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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)}.`);
|
||||
---
|
||||
<PageShell title="Order request" family="store" current="store" description="Send an order request for something from the Store.">
|
||||
<section class="intro" aria-labelledby="order-h1">
|
||||
<Breadcrumb here="Order request" />
|
||||
<h1 id="order-h1">Order request</h1>
|
||||
<p class="lede">Send a request and I will confirm the details and the total with you. No payment is taken through the site.</p>
|
||||
</section>
|
||||
|
||||
<div class="layout">
|
||||
<div class="main">
|
||||
{
|
||||
orderable.length > 0 ? (
|
||||
<OrderForm lines={orderable} selected={selected} storeTerms={st.terms} />
|
||||
) : (
|
||||
<div class="closed">
|
||||
{st.anyOpen ? (
|
||||
<Notice title="All order slots are taken right now.">Slots reopen as current orders finish.</Notice>
|
||||
) : (
|
||||
<Notice title="Orders currently closed.">{reopens.join(" ")}</Notice>
|
||||
)}
|
||||
<p>{st.settings?.capacityNote}</p>
|
||||
<div class="actions">
|
||||
<Button href="/store">Back to the Store</Button>
|
||||
<Button variant="secondary" href="/inquiries">Something custom? Start an inquiry</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside class="side" aria-label="Order status">
|
||||
{shown.map((l) => <LineStatus line={l} />)}
|
||||
{
|
||||
(turnarounds.length > 0 || st.settings?.capacityNote) && (
|
||||
<div class="know">
|
||||
<span class="know-head">
|
||||
<Star size={11} />
|
||||
<span>Good to know</span>
|
||||
</span>
|
||||
<TickScale width={160} height={10} count={24} />
|
||||
{turnarounds.map((t) => <span>{t}</span>)}
|
||||
{orderable.length > 0 && st.settings?.capacityNote && <span>{st.settings.capacityNote}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{orderable.length > 0 && <a href="/inquiries" class="gk-text inq">Something custom? Start an inquiry</a>}
|
||||
</aside>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
padding: 36px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 5vw, 44px);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--gk-soft);
|
||||
max-width: 64ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.layout {
|
||||
padding: 16px 0 64px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.main {
|
||||
flex: 3 1 600px;
|
||||
min-width: 0;
|
||||
}
|
||||
.closed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 32px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.closed :global(.gk-note) {
|
||||
font-size: 15px;
|
||||
}
|
||||
.closed p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.closed p:empty {
|
||||
display: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.side {
|
||||
flex: 1 1 280px;
|
||||
max-width: 360px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.know {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.know-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.inq {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
align-self: flex-start;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.side {
|
||||
max-width: none;
|
||||
}
|
||||
.closed {
|
||||
padding: 22px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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];
|
||||
---
|
||||
<PageShell title="Tabletop" family="tabletop" current="tabletop" description={text?.data.lede ?? "Campaigns, systems, decks and card games."}>
|
||||
<Fragment slot="hero">
|
||||
<PageHero eyebrow="My world" title={text?.title ?? "Tabletop"} lede={text?.data.lede} />
|
||||
</Fragment>
|
||||
|
||||
{nowItems.length > 0 && <NowStrip id="tt-now" heading="At the table now" items={nowItems} seal="astrolabe" />}
|
||||
|
||||
{intro && <div class="gk-prose intro" set:html={intro} />}
|
||||
|
||||
{
|
||||
systems.length > 0 && (
|
||||
<WorldSection id="tt-sys" title="Systems" intro="What I run, what I have played, and what I want to try.">
|
||||
{states.length > 1 && (
|
||||
<FilterGroup
|
||||
label="Filter systems by state"
|
||||
class="tt-filter"
|
||||
items={[{ label: "All", value: "all", active: true }, ...states.map((st) => ({ label: sentence(st), value: st }))]}
|
||||
/>
|
||||
)}
|
||||
<ul class="cards systems" data-filter-target>
|
||||
{systems.map((x) => {
|
||||
const runs = campaigns.filter((c) => s.byName(c.data.system)?.path === x.path);
|
||||
return (
|
||||
<li id={x.slug} class="entry" data-value={x.data.state}>
|
||||
<div class="e-top">
|
||||
<h3 class="e-name">{x.title}</h3>
|
||||
<StatusPill kind={systemPill(x.data.state)}>{sentence(x.data.state)}</StatusPill>
|
||||
</div>
|
||||
<p class="e-blurb">{x.data.blurb}</p>
|
||||
{runs.length > 0 && (
|
||||
<p class="e-foot">
|
||||
{runs.map((c, i) => (
|
||||
<>
|
||||
{i > 0 && " · "}
|
||||
<a href={`#${c.slug}`}>{c.title}</a>
|
||||
</>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{pastCampaigns.length > 0 && (
|
||||
<div class="past">
|
||||
<h3 class="past-h">Earlier campaigns</h3>
|
||||
<ul class="past-list">
|
||||
{pastCampaigns.map((c) => (
|
||||
<li id={c.slug}>
|
||||
<Star size={9} />
|
||||
<span>{c.title}</span>
|
||||
<StatusPill kind="neutral">{sentence(c.data.status)}</StatusPill>
|
||||
{c.data.blurb && <span class="past-note">{c.data.blurb}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
decks.length > 0 && (
|
||||
<WorldSection id="tt-decks" title={allDecks.some(isFav) ? "Favorite decks" : "Decks"} intro="Magic decks pull their details from Archidekt; other games link to wherever their lists live.">
|
||||
{archidekt && (
|
||||
<a slot="action" href={archidekt.url} class="gk-text all-decks" rel="me noopener">
|
||||
All my Magic decks on Archidekt
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{deckGroups.length > 1 && (
|
||||
<FilterGroup
|
||||
label="Filter decks by game"
|
||||
class="tt-filter"
|
||||
items={[{ label: "All games", value: "all", active: true }, ...deckGroups.map((g) => ({ label: g, value: g }))]}
|
||||
/>
|
||||
)}
|
||||
<ul class="cards decks" data-filter-target>
|
||||
{decks.map((d) => {
|
||||
const url = deckUrl(d);
|
||||
const game = gameOf(d);
|
||||
return (
|
||||
<li id={d.slug} class="deck" data-value={deckGroup(d)}>
|
||||
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" />
|
||||
<div class="d-txt">
|
||||
<span class="d-kick">{[shortGame(game.title), d.data.format].filter(Boolean).join(" · ")}</span>
|
||||
<div class="e-top">
|
||||
<h3 class="e-name d-name">
|
||||
{url ? (
|
||||
<a href={url} rel="noopener">
|
||||
{d.title}
|
||||
</a>
|
||||
) : (
|
||||
d.title
|
||||
)}
|
||||
{isFav(d) && <Star size={12} />}
|
||||
</h3>
|
||||
{deckOfMoment?.path === d.path && <StatusPill>Playing now</StatusPill>}
|
||||
</div>
|
||||
{d.data.take && <p class="e-blurb">{d.data.take}</p>}
|
||||
{url && (
|
||||
<a href={url} class="gk-text d-link" rel="noopener" aria-label={`${d.title} deck list`}>
|
||||
{d.data.archidektId ? "On Archidekt" : "Deck list"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
games.length > 0 && (
|
||||
<WorldSection id="tt-tcg" title="Card games" intro="Each game is one entry, so adding a new one is one note.">
|
||||
<ul class="cards games">
|
||||
{games.map((g) => {
|
||||
const n = s.all("deck").filter((d) => s.byName(d.data.game)?.path === g.path).length;
|
||||
return (
|
||||
<li id={g.slug} class="entry">
|
||||
<div class="e-top">
|
||||
<h3 class="e-name">{g.title}</h3>
|
||||
<StatusPill kind={stancePill(g.data.stance)}>{g.data.stance}</StatusPill>
|
||||
</div>
|
||||
{g.data.blurb && <p class="e-blurb">{g.data.blurb}</p>}
|
||||
{n > 0 && <p class="e-foot">{plural(n, "deck")} here</p>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
<nav class="more" aria-label="More from the table">
|
||||
<a href="/library/shelf" class="gk-card more-card">
|
||||
<span class="m-icon" aria-hidden="true"><Icon name="chess" size={30} /></span>
|
||||
<span class="m-txt">
|
||||
<span class="m-name">The Shelf</span>
|
||||
<span class="m-note">{shelfCount > 0 ? `${plural(shelfCount, "board game")}, filtered by how they play` : "Board games, filtered by how they play"}</span>
|
||||
</span>
|
||||
</a>
|
||||
<a href="/workshop" class="gk-card more-card">
|
||||
{workshopImg ? <img src={workshopImg} alt="" class="m-icon" loading="lazy" /> : <span class="m-icon" aria-hidden="true"><Icon name="hammer" size={30} /></span>}
|
||||
<span class="m-txt">
|
||||
<span class="m-name">Workshop</span>
|
||||
<span class="m-note">The dice, proxies and terrain I make for the table</span>
|
||||
</span>
|
||||
</a>
|
||||
<a href={`/blog/topic/${slugify("Tabletop")}`} class="gk-card more-card">
|
||||
<span class="m-icon" aria-hidden="true"><Icon name="list-text" size={30} /></span>
|
||||
<span class="m-txt">
|
||||
<span class="m-name">Tabletop posts</span>
|
||||
<span class="m-note">Session recaps, new games and deck techs</span>
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
</PageShell>
|
||||
|
||||
<script>
|
||||
// Filter pills: show the entries whose data-value matches the pressed pill.
|
||||
document.querySelectorAll<HTMLElement>(".tt-filter").forEach((group) => {
|
||||
const list = group.parentElement?.querySelector<HTMLElement>("[data-filter-target]");
|
||||
if (!list) return;
|
||||
group.classList.add("ready");
|
||||
group.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("button[data-value]");
|
||||
if (!btn) return;
|
||||
const value = btn.dataset.value;
|
||||
group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
|
||||
list.querySelectorAll<HTMLElement>(":scope > li").forEach((li) => {
|
||||
li.hidden = value !== "all" && li.dataset.value !== value;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
max-width: 70ch;
|
||||
padding-top: 28px;
|
||||
color: var(--gk-soft);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.intro :global(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
/* The filters only work with the script, which reveals them. */
|
||||
:global(.tt-filter) {
|
||||
display: none;
|
||||
}
|
||||
:global(.tt-filter.ready) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.cards > li[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.entry {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.games {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
|
||||
}
|
||||
.entry:target,
|
||||
.deck:target {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
.e-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.e-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.e-blurb {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.e-foot {
|
||||
margin: auto 0 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.e-foot a {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
|
||||
.past {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.past-h {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.past-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.past-list li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.past-note {
|
||||
color: var(--gk-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.all-decks {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.decks {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
|
||||
}
|
||||
.deck {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.deck :global(.d-art) {
|
||||
height: 150px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.d-txt {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.d-kick {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.d-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.d-name a {
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.d-name a:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.d-link {
|
||||
align-self: flex-start;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding-top: 48px;
|
||||
}
|
||||
.more-card {
|
||||
flex: 1 1 300px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
.m-icon {
|
||||
flex: none;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--gk-sky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.m-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.m-name {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
.m-note {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -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/<slug>; 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;
|
||||
---
|
||||
<PostLayout
|
||||
post={post}
|
||||
site={s}
|
||||
family="work"
|
||||
current="work"
|
||||
crumbs={[{ label: "Work", href: "/work" }, { label: d.employer ?? "Case study" }]}
|
||||
list={list}
|
||||
pool={pool}
|
||||
noun="case study"
|
||||
>
|
||||
<FactsStrip slot="facts" post={post} />
|
||||
</PostLayout>
|
||||
@@ -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.";
|
||||
---
|
||||
<PageShell title="Work" family="work" current="work" description="Case studies from employer work, personal projects and the homelab.">
|
||||
<Fragment slot="hero">
|
||||
<PageHero eyebrow="Work" title="Case studies" lede={lede} />
|
||||
</Fragment>
|
||||
|
||||
{
|
||||
groups.length > 1 && (
|
||||
<section aria-label="Filter case studies" class="filters" data-work-filters hidden>
|
||||
<FilterGroup
|
||||
label="Show"
|
||||
items={[{ label: "All", value: "all", active: true }, ...groups.map((g) => ({ label: groupLabels[g], value: g }))]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
studies.length === 0 && (
|
||||
<div class="empty">
|
||||
<Notice title="No case studies yet.">They are being written up now.</Notice>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{featured && <FeaturedStudy study={featured} href={s.href(featured)} core={core} />}
|
||||
|
||||
{
|
||||
rest.length > 0 && (
|
||||
<section aria-label="More case studies" class="more">
|
||||
<ul class="grid">
|
||||
{rest.map((p) => (
|
||||
<StudyCard study={p} href={s.href(p)} core={core} />
|
||||
))}
|
||||
</ul>
|
||||
<p class="none" data-work-none hidden>Nothing else in this group yet.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<section aria-labelledby="work-cta" class="cta">
|
||||
<div class="cta-inner">
|
||||
<div class="cta-text">
|
||||
<div class="heading">
|
||||
<h2 id="work-cta">Have something like this in mind?</h2>
|
||||
<span class="underline" aria-hidden="true"></span>
|
||||
</div>
|
||||
<span class="cta-sub">Jobs and freelance web work.</span>
|
||||
</div>
|
||||
<div class="cta-actions">
|
||||
<Button href="/inquiries">Start an inquiry</Button>
|
||||
<Button href="/resume" variant="secondary">See the resume</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
|
||||
<script>
|
||||
const bar = document.querySelector<HTMLElement>("[data-work-filters]");
|
||||
if (bar) {
|
||||
bar.hidden = false;
|
||||
const buttons = [...bar.querySelectorAll<HTMLButtonElement>("button[data-value]")];
|
||||
const items = [...document.querySelectorAll<HTMLElement>("[data-group]")];
|
||||
const none = document.querySelector<HTMLElement>("[data-work-none]");
|
||||
const apply = (value: string) => {
|
||||
buttons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.value === value)));
|
||||
let shownInGrid = 0;
|
||||
for (const el of items) {
|
||||
const show = value === "all" || el.dataset.group === value;
|
||||
el.hidden = !show;
|
||||
if (show && el.tagName === "LI") shownInGrid++;
|
||||
}
|
||||
if (none) none.hidden = shownInGrid > 0;
|
||||
};
|
||||
buttons.forEach((b) => b.addEventListener("click", () => apply(b.dataset.value ?? "all")));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
padding-top: 28px;
|
||||
}
|
||||
.empty {
|
||||
padding-top: 28px;
|
||||
}
|
||||
.more {
|
||||
padding: 24px 0 48px;
|
||||
}
|
||||
.grid {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.grid :global([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
.none {
|
||||
margin: 0;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.cta {
|
||||
margin-inline: calc(50% - 50vw);
|
||||
margin-top: 24px;
|
||||
background: var(--gk-midnight);
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.cta-inner {
|
||||
max-width: var(--gk-page-max);
|
||||
margin: 0 auto;
|
||||
padding: 40px var(--gk-gutter);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
.cta-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.underline {
|
||||
display: block;
|
||||
width: 180px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background:
|
||||
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent),
|
||||
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
|
||||
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
|
||||
}
|
||||
.cta-sub {
|
||||
font-size: 15px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.cta-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
---
|
||||
<PageShell title={craft.title} family="workshop" seal={seal} current="workshop" description={craft.data.blurb ?? `${craft.title} in Greg Kilmer's Workshop.`}>
|
||||
<Fragment slot="hero">
|
||||
<div class="crumb"><a href="/workshop">Workshop</a> / Craft</div>
|
||||
<h1 class="c-title"><Seal name={artSeal} size={64} />{craft.title}</h1>
|
||||
{craft.data.blurb && <p class="c-lede">{craft.data.blurb}</p>}
|
||||
<div class="c-meta">
|
||||
<StatusPill kind={craftPill(craft.data.status)}>{craft.data.status}</StatusPill>
|
||||
<span>
|
||||
{meta}
|
||||
{
|
||||
station && (
|
||||
<>
|
||||
{" · "}
|
||||
{station.href ? <a href={station.href}>{station.title}</a> : station.title}
|
||||
{" station"}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
{body && <div class="gk-prose body" set:html={body} />}
|
||||
|
||||
{nowItems.length > 0 && <NowStrip id="cr-now" heading="Right now" items={nowItems} seal={artSeal} />}
|
||||
|
||||
{
|
||||
tools.length > 0 && (
|
||||
<WorldSection id="cr-tools" title="Tools and materials">
|
||||
<ul class="tools">
|
||||
{tools.map(({ title, note }) => (
|
||||
<li class="tool">
|
||||
{note && <span class="t-kind">{note.data.kind === "other" ? "Tool" : sentence(note.data.kind)}</span>}
|
||||
<h3 class="t-name">{note ? <a href={s.href(note) ?? undefined}>{title}</a> : title}</h3>
|
||||
{note?.data.blurb && <span class="t-blurb">{note.data.blurb}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
<WorldSection id="cr-makes" title="Makes" intro={makes.length > 1 ? "Newest first." : undefined}>
|
||||
{
|
||||
makes.length > 0 ? (
|
||||
<ul class="makes">
|
||||
{makes.map((m) => {
|
||||
const photos = makePhotos(m);
|
||||
const log = s.backlinks(m, { type: "post" })[0];
|
||||
return (
|
||||
<li id={m.slug} class="make">
|
||||
<StarChartArt seal={artSeal} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
|
||||
{photos.length > 1 && (
|
||||
<span class="m-more">
|
||||
{photos.slice(1, 5).map((p, i) => (
|
||||
<img src={p} alt={`${m.title}, photo ${i + 2}`} loading="lazy" />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span class="m-txt">
|
||||
<h3 class="m-name">{m.title}</h3>
|
||||
{m.data.blurb && <span class="m-blurb">{m.data.blurb}</span>}
|
||||
{(m.data.date || log) && (
|
||||
<span class="m-meta">
|
||||
{m.data.date && <time datetime={m.data.date}>{formatDate(m.data.date)}</time>}
|
||||
{log && <Chip href={s.href(log) ?? undefined}>Build log</Chip>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="empty">Nothing finished in this craft yet.</p>
|
||||
)
|
||||
}
|
||||
</WorldSection>
|
||||
|
||||
{
|
||||
logs.length > 0 && (
|
||||
<WorldSection id="cr-logs" title="Build logs" intro={`Blog posts about ${craft.title.toLowerCase()} and its makes.`}>
|
||||
<ul class="logs">
|
||||
{logs.map((p) => (
|
||||
<li class="log">
|
||||
<StarChartArt seal={artSeal} ratio="auto" src={postCover(p)} class="l-art" />
|
||||
<div class="l-txt">
|
||||
<span class="l-kick">
|
||||
<span class="l-topic">{p.data.topic}</span>
|
||||
<time datetime={p.data.date}>{formatDate(p.data.date)}</time>
|
||||
</span>
|
||||
<a href={s.href(p) ?? undefined} class="l-title">{p.title}</a>
|
||||
{p.data.summary && <span class="l-sum">{p.data.summary}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
others.length > 0 && (
|
||||
<nav class="others" aria-label="Other crafts">
|
||||
<span class="o-label">Other crafts</span>
|
||||
{others.map((c) => <Chip href={s.href(c) ?? undefined}>{c.title}</Chip>)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
<CtaBand title="Want one of your own?" note="Custom pieces start as an inquiry.">
|
||||
<Button href="/inquiries">Start an inquiry</Button>
|
||||
</CtaBand>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.crumb {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crumb a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.crumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.c-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 6vw, 48px);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.c-lede {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: var(--gk-soft);
|
||||
max-width: 62ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.c-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.c-meta a {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.body {
|
||||
max-width: 70ch;
|
||||
padding-top: 28px;
|
||||
color: var(--gk-soft);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.tool {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-top: 2px solid var(--gk-gold);
|
||||
border-radius: var(--gk-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.t-kind {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.t-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.t-name a {
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.t-name a:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.t-blurb {
|
||||
font-size: 14px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.makes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.make {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.make:target {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
.make :global(.m-art) {
|
||||
height: 200px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.m-more {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
.m-more img {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.m-txt {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.m-name {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
.m-blurb {
|
||||
font-size: 13px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.m-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
|
||||
.logs {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.log {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: 164px;
|
||||
padding: 22px 0 20px;
|
||||
border-top: 1px dashed rgba(244, 242, 237, 0.16);
|
||||
}
|
||||
.log :global(.l-art) {
|
||||
flex: 0 0 176px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.l-txt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.l-kick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.l-topic {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.l-title {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.l-title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.l-title:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.l-sum {
|
||||
font-size: 14px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.log :global(.l-art) {
|
||||
flex-basis: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
.log {
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.others {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding-top: 32px;
|
||||
}
|
||||
.o-label {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
---
|
||||
<PageShell title="Workshop" family="workshop" current="workshop" description={text?.data.lede ?? "What I make, what I make it with, and what I am learning next."}>
|
||||
<Fragment slot="hero">
|
||||
<PageHero eyebrow="My world" title={text?.title ?? "Workshop"} lede={text?.data.lede} />
|
||||
</Fragment>
|
||||
|
||||
{intro && <div class="gk-prose intro" set:html={intro} />}
|
||||
|
||||
{nowItems.length > 0 && <NowStrip id="ws-now" heading="Right now" items={nowItems} seal="craft-print" />}
|
||||
|
||||
{
|
||||
stations.length > 0 && (
|
||||
<WorldSection id="ws-space" title="The workspace" intro={`${plural(stations.length, "station")} around the office.`}>
|
||||
<ol class="stations">
|
||||
{stations.map(({ station, tools }) => (
|
||||
<li id={station.slug} class="station">
|
||||
<span class="st-head">
|
||||
<TickRing size={32}>{station.data.order}</TickRing>
|
||||
<h3>{station.title}</h3>
|
||||
</span>
|
||||
<span class="where">{station.data.where}</span>
|
||||
{tools.length > 0 && (
|
||||
<ul class="tools">
|
||||
{tools.map((t) => (
|
||||
<li id={t.slug} title={t.data.blurb}>
|
||||
<Star size={9} />
|
||||
{t.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{station.data.goodFor && <span class="good">{station.data.goodFor}</span>}
|
||||
</li>
|
||||
))}
|
||||
{loose.length > 0 && (
|
||||
<li class="station">
|
||||
<span class="st-head">
|
||||
<TickRing size={32}><Star size={10} /></TickRing>
|
||||
<h3>Around the shop</h3>
|
||||
</span>
|
||||
<ul class="tools">
|
||||
{loose.map((t) => (
|
||||
<li id={t.slug}>
|
||||
<Star size={9} />
|
||||
{t.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
<WorldSection id="ws-crafts" title="Crafts" intro="Each craft keeps its own makes, tools and build logs, so the page stays short as the collection grows.">
|
||||
<ul class="crafts">
|
||||
{
|
||||
crafts.map(({ craft, makes, photos, logs, tools }) => (
|
||||
<li>
|
||||
<a href={s.href(craft) ?? undefined} class="gk-card craft">
|
||||
<span class="c-top">
|
||||
<h3 class="c-name">{craft.title}</h3>
|
||||
<StatusPill kind={craftPill(craft.data.status)}>{craft.data.status}</StatusPill>
|
||||
</span>
|
||||
<span class="thumbs" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) =>
|
||||
photos[i] ? (
|
||||
<img src={photos[i]} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span class="blank">
|
||||
<Seal name={craftSeal(craft) ?? "craft-print"} size={30} />
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
{craft.data.blurb && <span class="c-blurb">{craft.data.blurb}</span>}
|
||||
{tools.length > 0 && <span class="c-tools">{tools.join(" · ")}</span>}
|
||||
<span class="c-foot">
|
||||
<span>
|
||||
{plural(makes.length, "make")}
|
||||
{logs > 0 && ` · ${plural(logs, "build log")}`}
|
||||
</span>
|
||||
<span class="open">
|
||||
Open the craft
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
|
||||
{
|
||||
recent.length > 0 && (
|
||||
<WorldSection id="ws-recent" title="Recently finished">
|
||||
<ul class="recent">
|
||||
{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 (
|
||||
<li class="r-card">
|
||||
<a href={s.href(m) ?? undefined} class="r-art">
|
||||
<StarChartArt seal={(craft && craftSeal(craft)) ?? "craft-print"} ratio="auto" src={makePhotos(m)[0]} alt={m.data.blurb ?? m.title} class="r-img" />
|
||||
<span class="gk-visually-hidden">{m.title}</span>
|
||||
</a>
|
||||
<div class="r-txt">
|
||||
{craft && <span class="r-craft">{craft.title}</span>}
|
||||
<h3 class="r-name">
|
||||
<a href={s.href(m) ?? undefined}>{m.title}</a>
|
||||
</h3>
|
||||
{m.data.blurb && <span class="r-blurb">{m.data.blurb}</span>}
|
||||
{log && (
|
||||
<a href={s.href(log) ?? undefined} class="gk-text r-log">
|
||||
Blog: {log.title}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
swords.length > 0 && (
|
||||
<section class="rack gk-featured" aria-labelledby="ws-swords">
|
||||
<GiltCorners />
|
||||
<div class="rack-words">
|
||||
<span class="rack-kick">The sword rack</span>
|
||||
<h2 id="ws-swords">Blades from the stories I love</h2>
|
||||
<span class="rack-line" aria-hidden="true" />
|
||||
<span class="rack-note">I always pick the sword class, and the spellsword when I can.</span>
|
||||
</div>
|
||||
<ul class="blades">
|
||||
{swords.map((m) => (
|
||||
<li>
|
||||
<Star size={10} />
|
||||
<a href={s.href(m) ?? undefined} title={m.data.blurb}>
|
||||
{m.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<CtaBand title="Want one of these?" note="Some of it is in the Store. Anything else can start as an inquiry.">
|
||||
<Button href="/store">Visit the Store</Button>
|
||||
<Button href="/inquiries" variant="secondary">Start an inquiry</Button>
|
||||
</CtaBand>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
max-width: 70ch;
|
||||
padding-top: 28px;
|
||||
color: var(--gk-soft);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.intro :global(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
/* Stations */
|
||||
.stations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.station {
|
||||
flex: 1 1 250px;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-top: 2px solid var(--gk-gold);
|
||||
border-radius: var(--gk-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.st-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.st-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.where {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.tools {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tools li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.good {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 13px;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
|
||||
/* Crafts */
|
||||
.crafts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.craft {
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.c-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.c-name {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.thumbs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.thumbs > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumbs .blank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gk-sky);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.c-blurb {
|
||||
font-size: 14px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.c-tools {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.c-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--gk-soft);
|
||||
}
|
||||
.open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--gk-teal-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Recently finished */
|
||||
.recent {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.r-card {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.r-art {
|
||||
display: block;
|
||||
}
|
||||
.r-art :global(.r-img) {
|
||||
height: 170px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.r-txt {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.r-craft {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.r-name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.r-name a {
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.r-name a:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.r-blurb {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.r-log {
|
||||
font-size: 13px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Sword rack */
|
||||
.rack {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
.rack-words {
|
||||
flex: 1 1 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.rack-kick {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rack h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.rack-line {
|
||||
display: block;
|
||||
width: 180px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background:
|
||||
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
|
||||
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
|
||||
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
|
||||
}
|
||||
.rack-note {
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.blades {
|
||||
flex: 2 1 480px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 8px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.blades li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.blades a {
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.blades a:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.rack {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.blades {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user