mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Match the site to the approved designs
Denser star field, the page nebula, a favicon package, the filament banner without its box, the About portrait, Kashi and tea photos and Scorpius, the Store's own header, a header logo glow, a resume PDF that fills its page, one divider on the Library and Shelf, star-chart art for coverless favorites, Shelf Players, Play time and blog filters with a Show all fold, make kind pills on craft pages, and full-width closing bands. 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:
@@ -128,6 +128,7 @@ export const sorts = [
|
||||
{ key: "played", label: "Last played" },
|
||||
{ key: "az", label: "A to Z" },
|
||||
{ key: "added", label: "Recently added" },
|
||||
{ key: "time", label: "Play time" },
|
||||
] as const;
|
||||
export type SortKey = (typeof sorts)[number]["key"];
|
||||
|
||||
@@ -138,6 +139,8 @@ export function sortGames(games: Game[], sort: SortKey): Game[] {
|
||||
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
|
||||
az,
|
||||
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
|
||||
// Shortest first; games BoardGameGeek has not timed go last.
|
||||
time: (a, b) => (minutesOf(a) ?? Infinity) - (minutesOf(b) ?? Infinity) || az(a, b),
|
||||
};
|
||||
return [...games].sort(cmp[sort]);
|
||||
}
|
||||
@@ -148,9 +151,29 @@ export interface ShelfFilter {
|
||||
owned: boolean;
|
||||
want: boolean;
|
||||
favorites: boolean;
|
||||
blog: boolean;
|
||||
players: PlayersKey | "";
|
||||
time: TimeKey | "";
|
||||
sort: SortKey;
|
||||
}
|
||||
|
||||
/** The Players pills: who a game has to seat. */
|
||||
export const playerOptions = [
|
||||
{ key: "1", label: "1", lo: 1, hi: 1 },
|
||||
{ key: "2", label: "2", lo: 2, hi: 2 },
|
||||
{ key: "3-4", label: "3–4", lo: 3, hi: 4 },
|
||||
{ key: "5+", label: "5+", lo: 5, hi: Infinity },
|
||||
] as const;
|
||||
export type PlayersKey = (typeof playerOptions)[number]["key"];
|
||||
|
||||
/** The Play time pills, in minutes. */
|
||||
export const timeOptions = [
|
||||
{ key: "short", label: "Under 30 min", lo: 0, hi: 29 },
|
||||
{ key: "mid", label: "30–60", lo: 30, hi: 60 },
|
||||
{ key: "long", label: "60+", lo: 61, hi: Infinity },
|
||||
] as const;
|
||||
export type TimeKey = (typeof timeOptions)[number]["key"];
|
||||
|
||||
export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
||||
const submitted = params.has("f");
|
||||
return {
|
||||
@@ -159,19 +182,27 @@ export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
||||
owned: submitted ? params.get("owned") === "1" : true,
|
||||
want: params.get("want") === "1",
|
||||
favorites: params.get("fav") === "1",
|
||||
blog: params.get("blog") === "1",
|
||||
players: playerOptions.find((o) => o.key === params.get("players"))?.key ?? "",
|
||||
time: timeOptions.find((o) => o.key === params.get("time"))?.key ?? "",
|
||||
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owned and Want to play add games to the list (either one); with neither
|
||||
* ticked, every game shows. Favorites only and How it plays narrow it.
|
||||
* ticked, every game shows. Favorites only, Has a blog post, How it plays,
|
||||
* Players and Play time narrow it. A game BoardGameGeek has not synced yet
|
||||
* drops out while Players or Play time is set.
|
||||
*/
|
||||
export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
|
||||
export function gameShows(g: Game, f: ShelfFilter, text: string, hasPost = false): boolean {
|
||||
const owned = !!g.data.owned;
|
||||
const want = !!g.data.wantToPlay;
|
||||
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
|
||||
if (f.favorites && !g.data.favorite) return false;
|
||||
if (f.blog && !hasPost) return false;
|
||||
if (f.players && !seats(g, playerOptions.find((o) => o.key === f.players)!)) return false;
|
||||
if (f.time && !lasts(g, timeOptions.find((o) => o.key === f.time)!)) return false;
|
||||
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
|
||||
return !f.q || text.includes(f.q);
|
||||
}
|
||||
@@ -185,6 +216,30 @@ interface GameSynced {
|
||||
maxPlayTime?: number;
|
||||
}
|
||||
|
||||
/** The player range BoardGameGeek gives, when it has synced. */
|
||||
export function playersOf(g: Game): [number, number] | undefined {
|
||||
const d = g.data as GameSynced;
|
||||
const lo = d.minPlayers || d.maxPlayers;
|
||||
const hi = d.maxPlayers || d.minPlayers;
|
||||
return lo && hi ? [lo, hi] : undefined;
|
||||
}
|
||||
|
||||
/** A game's usual length in minutes: the listed play time, else the longest. */
|
||||
export function minutesOf(g: Game): number | undefined {
|
||||
const d = g.data as GameSynced;
|
||||
return d.playTime || d.maxPlayTime || d.minPlayTime || undefined;
|
||||
}
|
||||
|
||||
/** Whether a game plays at some count in the option's range. */
|
||||
const seats = (g: Game, o: { lo: number; hi: number }) => {
|
||||
const r = playersOf(g);
|
||||
return !!r && r[0] <= o.hi && r[1] >= o.lo;
|
||||
};
|
||||
const lasts = (g: Game, o: { lo: number; hi: number }) => {
|
||||
const m = minutesOf(g);
|
||||
return m !== undefined && m >= o.lo && m <= o.hi;
|
||||
};
|
||||
|
||||
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
|
||||
|
||||
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
|
||||
|
||||
@@ -43,7 +43,21 @@ interface Col {
|
||||
w: number;
|
||||
}
|
||||
|
||||
function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
interface Body {
|
||||
pdf: Buffer;
|
||||
pages: number;
|
||||
/** Where the last line ended on the last page, in points from the top. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the resume out with every gap between blocks multiplied by `spread`
|
||||
* (and line spacing by a little less), so a short resume can be opened up to
|
||||
* fill its one page instead of leaving the bottom empty.
|
||||
*/
|
||||
function renderBody(r: ResumeData, spread = 1): Promise<Body> {
|
||||
const gapDown = (n: number) => doc.moveDown(n * spread);
|
||||
const lg = (n: number) => n + (spread - 1) * 1.2;
|
||||
const doc = new PDFDocument({
|
||||
size: PAGE.size,
|
||||
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right },
|
||||
@@ -57,8 +71,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
let pages = 1;
|
||||
let end = 0;
|
||||
const done = new Promise<Body>((resolve, reject) => {
|
||||
doc.on("end", () => resolve({ pdf: Buffer.concat(chunks), pages, end }));
|
||||
doc.on("error", reject);
|
||||
});
|
||||
|
||||
@@ -73,7 +89,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
|
||||
const section = (title: string, c: Col = full) => {
|
||||
room(50);
|
||||
doc.moveDown(0.4);
|
||||
gapDown(0.4);
|
||||
const y = doc.y;
|
||||
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
|
||||
const ruleY = doc.y + 1;
|
||||
@@ -85,16 +101,16 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
const indent = 11;
|
||||
const w = c.w - indent;
|
||||
doc.font("body").fontSize(TEXT);
|
||||
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1 }) + 3);
|
||||
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: lg(1) }) + 3);
|
||||
const y = doc.y;
|
||||
diamond(c.x + 3.5, y + 5.5, 2.1);
|
||||
if (lead) {
|
||||
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1 });
|
||||
doc.font("body").text(` ${text}`, { lineGap: 1 });
|
||||
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: lg(1) });
|
||||
doc.font("body").text(` ${text}`, { lineGap: lg(1) });
|
||||
} else {
|
||||
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1 });
|
||||
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: lg(1) });
|
||||
}
|
||||
doc.moveDown(0.12);
|
||||
gapDown(0.12);
|
||||
};
|
||||
|
||||
/** A title on the left with its dates set flush right on the same line. */
|
||||
@@ -122,20 +138,20 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
|
||||
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
|
||||
{
|
||||
const y = doc.moveDown(0.15).y;
|
||||
const y = gapDown(0.15).y;
|
||||
doc.font("body").fontSize(8).fillColor(MUTED);
|
||||
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
|
||||
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
|
||||
doc.text(links, full.x, y, { width: full.w - 120 });
|
||||
}
|
||||
doc.moveDown(0.6);
|
||||
gapDown(0.6);
|
||||
|
||||
if (r.summary) {
|
||||
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.4 });
|
||||
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: lg(1.4) });
|
||||
}
|
||||
|
||||
if (r.stats.length) {
|
||||
doc.moveDown(0.6);
|
||||
gapDown(0.6);
|
||||
const cols = Math.min(r.stats.length, 4);
|
||||
const colW = full.w / cols;
|
||||
const top = doc.y;
|
||||
@@ -156,14 +172,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
section("Experience");
|
||||
r.roles.forEach((role, i) => {
|
||||
const d = role.data;
|
||||
if (i > 0) doc.moveDown(0.5);
|
||||
if (i > 0) gapDown(0.5);
|
||||
titled(d.role, period(role), 10.5);
|
||||
const at = [d.company, d.location].filter(Boolean).join(" · ");
|
||||
doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
|
||||
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
|
||||
// The focus line repeats what the bullets say, so paper skips it when there are bullets.
|
||||
if (d.focus && !d.bullets?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
|
||||
doc.moveDown(0.25);
|
||||
gapDown(0.25);
|
||||
for (const b of d.bullets ?? []) bullet(b);
|
||||
doc.x = full.x;
|
||||
});
|
||||
@@ -173,7 +189,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
section("Earlier");
|
||||
r.earlier.forEach((role, i) => {
|
||||
const d = role.data;
|
||||
if (i > 0) doc.moveDown(0.3);
|
||||
if (i > 0) gapDown(0.3);
|
||||
titled(d.role, period(role), 9.5);
|
||||
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w });
|
||||
if (d.focus) doc.font("body").fontSize(8).fillColor(MUTED).text(d.focus, { width: full.w });
|
||||
@@ -193,14 +209,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
doc.font("strong");
|
||||
const lh = doc.heightOfString(g.label, { width: labelW });
|
||||
doc.font("body");
|
||||
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.6 })) + 2;
|
||||
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: lg(0.6) })) + 2;
|
||||
}, 24);
|
||||
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
|
||||
doc.font("body");
|
||||
const rightH =
|
||||
(entries ? 24 + entries * 26 : 0) +
|
||||
(r.projects.length ? 24 : 0) +
|
||||
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1 }) + 3, 0);
|
||||
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: lg(1) }) + 3, 0);
|
||||
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
|
||||
const start = doc.y;
|
||||
let leftEnd = start;
|
||||
@@ -211,7 +227,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
const y = doc.y;
|
||||
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
|
||||
const afterLabel = doc.y;
|
||||
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.6 });
|
||||
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: lg(0.6) });
|
||||
doc.y = Math.max(doc.y, afterLabel) + 2;
|
||||
}
|
||||
leftEnd = doc.y;
|
||||
@@ -224,12 +240,12 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
for (const dg of edu?.degrees ?? []) {
|
||||
titled(dg.credential, dg.period, 9.5, rightCol);
|
||||
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
|
||||
doc.moveDown(0.3);
|
||||
gapDown(0.3);
|
||||
}
|
||||
for (const c of edu?.certifications ?? []) {
|
||||
titled(c.name, c.year ?? "", 9.5, rightCol);
|
||||
if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w });
|
||||
doc.moveDown(0.3);
|
||||
gapDown(0.3);
|
||||
}
|
||||
}
|
||||
if (r.projects.length) {
|
||||
@@ -239,8 +255,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
doc.x = full.x;
|
||||
doc.y = Math.max(doc.y, leftEnd);
|
||||
|
||||
end = doc.y;
|
||||
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
|
||||
const range = doc.bufferedPageRange();
|
||||
pages = range.count;
|
||||
if (range.count > 1) {
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
@@ -257,9 +275,29 @@ function renderBody(r: ResumeData): Promise<Buffer> {
|
||||
return done;
|
||||
}
|
||||
|
||||
/** The page's lowest writing line, in points from the top. */
|
||||
const LIMIT = 792 - PAGE.bottom;
|
||||
|
||||
/**
|
||||
* A one-page resume that ends well above the footer is laid out again with
|
||||
* wider gaps, up to twice as wide, so the page reads as full; the widest
|
||||
* spread that still fits on one page wins. Longer resumes are left as they are.
|
||||
*/
|
||||
export async function fitBody(r: ResumeData): Promise<Body> {
|
||||
const first = await renderBody(r);
|
||||
if (first.pages > 1 || LIMIT - first.end < 24) return first;
|
||||
let best = first;
|
||||
for (let spread = 1.1; spread <= 2.001; spread += 0.1) {
|
||||
const next = await renderBody(r, spread);
|
||||
if (next.pages > 1 || next.end > LIMIT - 4) break;
|
||||
best = next;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The resume laid over the letterhead: each page draws the letterhead, then the resume on top, both as vectors. */
|
||||
export async function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||
const body = await PdfLib.load(await renderBody(r));
|
||||
const body = await PdfLib.load((await fitBody(r)).pdf);
|
||||
const out = await PdfLib.create();
|
||||
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
|
||||
out.setAuthor(r.name);
|
||||
|
||||
@@ -164,3 +164,12 @@ const CATEGORY_ICONS: Record<Category, string> = { Tabletop: "dice", Paper: "boo
|
||||
export function lineIcon(line: Line): string {
|
||||
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Store's own header: the Store, each line sold here (by its menu name),
|
||||
* and Order request. Lines sold elsewhere stay on the Store page only.
|
||||
*/
|
||||
export function storeNav(st: StoreState, current: string): { links: { label: string; href: string }[]; current: string } {
|
||||
const lines = st.lines.filter((l) => channelOf(l) === "here").map((l) => ({ label: l.data.menuName ?? l.title, href: `/store/${l.slug}` }));
|
||||
return { links: [{ label: "Store", href: "/store" }, ...lines, { label: "Order request", href: "/store/order" }], current };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user