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,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>
|
||||
Reference in New Issue
Block a user