Phase 6: sync jobs, inquiry and order forms

- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

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:
Claude
2026-10-11 23:50:06 +00:00
parent 60dfeecd7f
commit e3b0cf60c5
76 changed files with 6050 additions and 86 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* POST /api/inquiries: the Inquiries form (multipart, works without script).
* Checks the post, the rate limit and Turnstile, saves a copy through the
* content service, emails Greg, and redirects (303) to /inquiries/sent with
* the gk-inquiry cookie. A post that is turned down goes back to /inquiries
* with the reason and the visitor's answers.
*/
import type { APIRoute } from "astro";
import {
clientIp,
encodeDraft,
formValues,
inquiryEmail,
inquiryFields,
inquirySent,
isBot,
saveAndSend,
TURNSTILE_FIELD,
validateInquiry,
type ErrorCode,
type FieldErrors,
} from "../../lib/forms.ts";
import {
contactTo,
flashCookie,
inquiryLimiter,
maxAttachmentBytes,
reportDelivery,
saveSubmission,
sendMail,
verifyTurnstile,
} from "../../lib/forms-server.ts";
export const prerender = false;
export const POST: APIRoute = async ({ request, cookies, redirect, url, clientAddress }) => {
let form: FormData;
try {
form = await request.formData();
} catch {
return redirect("/inquiries?error=invalid", 303);
}
const type = String(form.get("type") ?? "");
const back = (code: ErrorCode, errors: FieldErrors = {}) => {
cookies.set("gk-inquiry-draft", encodeDraft({ code, errors, values: formValues(form) }), flashCookie(url));
const q = new URLSearchParams({ error: code });
if (/^[a-z]{1,20}$/.test(type)) q.set("type", type);
return redirect(`/inquiries?${q}`, 303);
};
// A bot filled the hidden field: answer as if it worked, keep nothing.
if (isBot(form)) return redirect("/inquiries/sent", 303);
const checked = validateInquiry(form, { maxBytes: maxAttachmentBytes() });
if (!checked.ok) return back(checked.code, checked.errors);
const q = checked.value;
let ip = "unknown";
try {
ip = clientIp(request.headers, clientAddress);
} catch {
ip = clientIp(request.headers);
}
if (inquiryLimiter.hit(ip)) return back("rate");
if (!(await verifyTurnstile(String(form.get(TURNSTILE_FIELD) ?? ""), ip))) return back("bot");
const result = await saveAndSend({
save: () => saveSubmission({ kind: "inquiry", type: q.type, name: q.name, email: q.email, fields: inquiryFields(q), attachment: q.attachment }),
email: (savedId) => sendMail({ to: contactTo(), replyTo: { name: q.name, address: q.email }, ...inquiryEmail(q, savedId), attachment: q.attachment }),
report: reportDelivery,
});
if (!result.ok) return back("failed");
cookies.set("gk-inquiry", JSON.stringify(inquirySent(q)), flashCookie(url));
return redirect("/inquiries/sent", 303);
};
+80
View File
@@ -0,0 +1,80 @@
/**
* POST /api/orders: the Store's order request form (works without script).
* Checks the post, the rate limit and Turnstile, then re-checks the line
* against the live store notes: an order is taken only when its category's
* ordersOpen is true and the line has an open slot or stock, whatever the
* post says. Then it saves a copy, emails Greg, and redirects (303) to
* /store/order/sent with the gk-order cookie. No payment is taken.
*/
import type { APIRoute } from "astro";
import {
checkOrder,
clientIp,
encodeDraft,
formValues,
isBot,
orderEmail,
orderFields,
orderSent,
saveAndSend,
TURNSTILE_FIELD,
validateOrderFields,
type ErrorCode,
type FieldErrors,
} from "../../lib/forms.ts";
import { flashCookie, liveStore, orderLimiter, orderTo, reportDelivery, saveSubmission, sendMail, verifyTurnstile } from "../../lib/forms-server.ts";
import type { StoreState } from "../../lib/store.ts";
export const prerender = false;
export const POST: APIRoute = async ({ request, cookies, redirect, url, clientAddress }) => {
let form: FormData;
try {
form = await request.formData();
} catch {
return redirect("/store/order?error=invalid", 303);
}
const line = String(form.get("line") ?? "");
const back = (code: ErrorCode, errors: FieldErrors = {}) => {
cookies.set("gk-order-draft", encodeDraft({ code, errors, values: formValues(form) }), flashCookie(url));
const q = new URLSearchParams({ error: code });
if (/^[a-z0-9-]{1,120}$/.test(line)) q.set("line", line);
return redirect(`/store/order?${q}`, 303);
};
if (isBot(form)) return redirect("/store/order/sent", 303);
const fields = validateOrderFields(form);
if (!fields.ok) return back(fields.code, fields.errors);
let ip = "unknown";
try {
ip = clientIp(request.headers, clientAddress);
} catch {
ip = clientIp(request.headers);
}
if (orderLimiter.hit(ip)) return back("rate");
if (!(await verifyTurnstile(String(form.get(TURNSTILE_FIELD) ?? ""), ip))) return back("bot");
// The gate: always the live notes, never the cached copy or the form.
let st: StoreState;
try {
st = await liveStore();
} catch (err) {
console.error("Could not read the live store notes; turning the order down.", err);
return back("unavailable");
}
const checked = checkOrder(fields.value, form, st);
if (!checked.ok) return back(checked.code, checked.errors);
const o = checked.value;
const result = await saveAndSend({
save: () => saveSubmission({ kind: "order", type: o.lineNote.slug, name: o.name, email: o.email, fields: orderFields(o) }),
email: (savedId) => sendMail({ to: orderTo(), replyTo: { name: o.name, address: o.email }, ...orderEmail(o, savedId) }),
report: reportDelivery,
});
if (!result.ok) return back("failed");
cookies.set("gk-order", JSON.stringify(orderSent(o)), flashCookie(url));
return redirect("/store/order/sent", 303);
};
+68 -23
View File
@@ -1,20 +1,31 @@
---
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import Notice from "@gk/ui/components/Notice.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 { env } from "../../lib/env.ts";
import { choices, errorMessages, HONEYPOT, parseDraft, parseErrorCode, timing } from "../../lib/forms.ts";
import { turnstileSiteKey } from "../../lib/forms-server.ts";
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"));
// A post that was turned down comes back with its reason and the answers given.
const draft = parseDraft(Astro.cookies.get("gk-inquiry-draft")?.value);
if (draft) {
Astro.cookies.delete("gk-inquiry-draft", { path: "/" });
Astro.response.headers.set("Cache-Control", "private, no-store");
}
const errorCode = draft?.code ?? parseErrorCode(Astro.url.searchParams.get("error"));
const v = draft?.values ?? {};
const err = draft?.errors ?? {};
const selected = parseInquiryType(v.type ?? 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 ${s.settings()?.replyTime ?? "a few days"}`, "Read by me, not a bot"];
const socials = settings?.socials ?? [];
@@ -25,12 +36,7 @@ const placeholders: Record<InquiryType | "none", string> = {
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 turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY");
const turnstileKey = turnstileSiteKey();
---
<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>}
@@ -74,8 +80,13 @@ const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY
<form class="form" method="post" action="/api/inquiries" enctype="multipart/form-data" aria-label="Inquiry" data-inquiry>
<div class="frame">
{errorCode && (
<Notice role="alert" title={errorMessages[errorCode]}>
{Object.keys(err).length > 0 ? "The marked answers are below." : ""}
</Notice>
)}
<fieldset class="types">
<legend>What's this about?</legend>
<legend>What's this about?{err.type && <span class="err"> {err.type}</span>}</legend>
<div class="tiles">
{
inquiryTypes.map((t) => (
@@ -91,46 +102,68 @@ const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY
</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} />
<Field label="Your name" name="name" placeholder="Jane Doe" autocomplete="name" required maxlength={120} value={v.name} error={err.name} />
<Field label="Email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required maxlength={200} value={v.email} error={err.email} />
</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} />
<Field label="Company" name="job_company" placeholder="Where is the role?" autocomplete="organization" maxlength={160} value={v.job_company} error={err.job_company} />
<Field label="Role" name="job_role" placeholder="e.g. Senior full-stack developer" maxlength={160} value={v.job_role} error={err.job_role} />
</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} />
<ChoicePills legend="Kind of role" name="job_kind" options={[...choices.job_kind]} checked={v.job_kind} />
<Field label="Link to the posting" name="job_link" type="url" placeholder="https://" hint="Optional." maxlength={500} value={v.job_link} error={err.job_link} />
</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"]} />
<ChoicePills legend="What do you need?" name="freelance_need" options={[...choices.freelance_need]} checked={v.freelance_need} />
<div class="pair">
<Field label="Your current site" name="freelance_site" type="url" placeholder="https://" hint="Optional." maxlength={500} />
<Field label="Your current site" name="freelance_site" type="url" placeholder="https://" hint="Optional." maxlength={500} value={v.freelance_site} error={err.freelance_site} />
<Field label="Timeline" name="freelance_timing" as="select" options={timing} />
</div>
<Field label="Date" name="freelance_date" type="date" class="when-date" />
<Field label="Date" name="freelance_date" type="date" class="when-date" value={v.freelance_date} error={err.freelance_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"]} />
<ChoicePills legend="What would you like made?" name="patiun_item" options={[...choices.patiun_item]} checked={v.patiun_item} />
<div class="pair">
<Field label="How many?" name="patiun_quantity" placeholder="e.g. 60 cards" maxlength={120} />
<Field label="How many?" name="patiun_quantity" placeholder="e.g. 60 cards" maxlength={120} value={v.patiun_quantity} error={err.patiun_quantity} />
<Field label="Need it by" name="patiun_timing" as="select" options={timing} />
</div>
<Field label="Date" name="patiun_date" type="date" class="when-date" />
<Field label="Date" name="patiun_date" type="date" class="when-date" value={v.patiun_date} error={err.patiun_date} />
</div>
<Field label="Details" name="details" as="textarea" rows={5} required maxlength={5000} placeholder={placeholders[selected ?? "none"]} data-details />
<div class="gk-label">
<label class="gk-label-text" for="f-details">Details</label>
<textarea
id="f-details"
name="details"
class="gk-field"
rows={5}
required
minlength={10}
maxlength={5000}
placeholder={placeholders[selected ?? "none"]}
aria-describedby={err.details ? "f-details-error" : undefined}
aria-invalid={err.details ? "true" : undefined}
data-details>{v.details ?? ""}</textarea>
{err.details && <span class="err" id="f-details-error">{err.details}</span>}
</div>
<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>
{err.attachment && <span class="err">{err.attachment}</span>}
{draft && !err.attachment && <span class="note">If you attached a file, add it again.</span>}
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark"></div>}
<div class="hp" aria-hidden="true">
<label>Leave this empty <input type="text" name={HONEYPOT} tabindex="-1" autocomplete="off" /></label>
</div>
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark" data-response-field-name="cf-turnstile-response"></div>}
{turnstileKey && <noscript><span class="note">The bot check needs JavaScript. Turn it on for this page to send the form.</span></noscript>}
<div class="submit">
<Button type="submit">Send inquiry</Button>
@@ -406,6 +439,18 @@ const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY
font-size: 13px;
color: var(--gk-muted);
}
.err {
font-size: 12px;
font-weight: 400;
color: #f0857a;
}
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 640px) {
.tiles {
grid-template-columns: 1fr;
+2 -1
View File
@@ -3,7 +3,8 @@
* 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.
* counts, play times and box art sync from BoardGameGeek (a cover set in the
* note wins); no filter for them yet.
*/
import Divider from "@gk/ui/components/Divider.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
+23 -2
View File
@@ -15,10 +15,20 @@ import OrderForm from "../../components/store/OrderForm.astro";
import { site } from "../../lib/page.ts";
import { formatDate } from "../../lib/site.ts";
import { canOrder, channelOf, storeState } from "../../lib/store.ts";
import { errorMessages, parseDraft, parseErrorCode } from "../../lib/forms.ts";
import { turnstileSiteKey } from "../../lib/forms-server.ts";
const s = await site();
const st = storeState(s);
const selected = Astro.url.searchParams.get("line") ?? undefined;
// A post that was turned down comes back with its reason and the answers given.
const draft = parseDraft(Astro.cookies.get("gk-order-draft")?.value);
if (draft) {
Astro.cookies.delete("gk-order-draft", { path: "/" });
Astro.response.headers.set("Cache-Control", "private, no-store");
}
const errorCode = draft?.code ?? parseErrorCode(Astro.url.searchParams.get("error"));
const turnstileKey = turnstileSiteKey();
const selected = draft?.values.line ?? 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;
@@ -26,6 +36,7 @@ const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround
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.">
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
<section class="intro" aria-labelledby="order-h1">
<Breadcrumb here="Order request" />
<h1 id="order-h1">Order request</h1>
@@ -34,9 +45,16 @@ const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens
<div class="layout">
<div class="main">
{errorCode && (
<div class="problem">
<Notice role="alert" title={errorMessages[errorCode]}>
{draft && Object.keys(draft.errors).length > 0 ? "The marked answers are below." : ""}
</Notice>
</div>
)}
{
orderable.length > 0 ? (
<OrderForm lines={orderable} selected={selected} storeTerms={st.terms} />
<OrderForm lines={orderable} selected={selected} storeTerms={st.terms} values={draft?.values} errors={draft?.errors} turnstileKey={turnstileKey} />
) : (
<div class="closed">
{st.anyOpen ? (
@@ -104,6 +122,9 @@ const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens
flex: 3 1 600px;
min-width: 0;
}
.problem {
margin-bottom: 16px;
}
.closed {
display: flex;
flex-direction: column;
@@ -0,0 +1,204 @@
---
/**
* The confirmation after an order request. /api/orders sets the short-lived
* `gk-order` cookie (URL-encoded JSON: name, email, line, rows) before it
* redirects here; the page shows it once and clears it. Without the cookie
* the page still reads correctly.
*/
import Button from "@gk/ui/components/Button.astro";
import PageShell from "@gk/ui/components/PageShell.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 Breadcrumb from "../../../components/store/Breadcrumb.astro";
import { parseOrderSent } from "../../../lib/forms.ts";
import { site } from "../../../lib/page.ts";
import { storeState } from "../../../lib/store.ts";
const sent = parseOrderSent(Astro.cookies.get("gk-order")?.value);
if (sent) Astro.cookies.delete("gk-order", { path: "/" });
Astro.response.headers.set("Cache-Control", "private, no-store");
const s = await site().catch(() => null);
const replyTime = s?.settings()?.replyTime ?? "a few days";
const line = sent?.lineSlug && s ? storeState(s).lines.find((l) => l.slug === sent.lineSlug) : undefined;
const turnaround = line && s ? storeState(s).category(line.data.category).turnaround : undefined;
const rows = [
...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
...(sent?.rows ?? []),
...(sent?.email ? [{ label: "Reply to", value: sent.email }] : []),
];
const firstName = sent?.name?.split(/\s+/)[0];
const steps = [
{ title: "I check the request", text: "The details, the options and my queue." },
{ title: "I confirm with you", text: "The total and the timing, by email. No payment is taken through the site." },
{ title: "I start once you say yes", text: turnaround ?? "Nothing is made until we agree." },
];
---
<PageShell title="Order request sent" family="store" current="store" description="Your order request is on its way.">
<meta slot="head" name="robots" content="noindex" />
<section class="intro" aria-labelledby="sent-h1">
<Breadcrumb here="Request sent" />
<h1 id="sent-h1">Your request is in</h1>
<p class="lede">Thanks{firstName ? `, ${firstName}` : ""}. I read every order request myself and reply within {replyTime}.</p>
</section>
<div class="layout">
<div class="main">
<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 class="actions">
<Button href="/store">Back to the Store</Button>
<Button variant="secondary" href="/inquiries">Something custom? Start an inquiry</Button>
</div>
</div>
{
rows.length > 0 && (
<aside 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>
</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 480px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.steps {
margin: 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;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.sent {
flex: 1 1 300px;
max-width: 420px;
min-width: 0;
padding: 24px;
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;
}
@media (max-width: 700px) {
.sent {
max-width: none;
}
}
</style>
+8 -2
View File
@@ -11,7 +11,7 @@ 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";
import { byOrder, deckArt, deckColors, deckCommanders, deckUrl, makePhotos, sentence, stancePill, systemPill, systemStates } from "../lib/world.ts";
const s = await site();
const text = s.pageText("/tabletop");
@@ -151,7 +151,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
const game = gameOf(d);
return (
<li id={d.slug} class="deck" data-value={deckGroup(d)}>
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" />
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" src={deckArt(d)} alt={deckCommanders(d).join(" and ")} />
<div class="d-txt">
<span class="d-kick">{[shortGame(game.title), d.data.format].filter(Boolean).join(" · ")}</span>
<div class="e-top">
@@ -167,6 +167,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
</h3>
{deckOfMoment?.path === d.path && <StatusPill>Playing now</StatusPill>}
</div>
{deckColors(d).length > 0 && <p class="d-colors">{deckColors(d).join(" · ")}</p>}
{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`}>
@@ -401,6 +402,11 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
.d-name a:hover {
color: var(--gk-gold-bright);
}
.d-colors {
margin: 0;
font-size: 12px;
color: var(--gk-muted);
}
.d-link {
align-self: flex-start;
font-size: 13px;