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,164 @@
|
||||
/**
|
||||
* The Library and The Shelf: how items group, filter and sort. The pages
|
||||
* filter on the server from the query string (so they work without JS) and a
|
||||
* small script repeats the same rules live in the browser.
|
||||
*/
|
||||
import type { Note } from "./content.ts";
|
||||
import type { SiteIndex } from "./site.ts";
|
||||
|
||||
export type Item = Note<"library-item">;
|
||||
export type Game = Note<"board-game">;
|
||||
|
||||
// ---------------------------------------------------------------- Library
|
||||
|
||||
/** The list's three groups. Dropped items are left out. */
|
||||
export const sections = [
|
||||
{ key: "now", label: "Now", states: ["now"] },
|
||||
{ key: "next", label: "Up next", states: ["up next"] },
|
||||
{ key: "done", label: "Finished", states: ["finished", "caught up", "played"] },
|
||||
] as const;
|
||||
export type SectionKey = (typeof sections)[number]["key"];
|
||||
|
||||
export const sectionOf = (item: Item): SectionKey | undefined =>
|
||||
sections.find((s) => (s.states as readonly string[]).includes(item.data.state))?.key;
|
||||
|
||||
/** The Type filter: media gathered the way the rail lists them. */
|
||||
export const mediaGroups = [
|
||||
{ key: "books", label: "Books", media: ["book", "light novel"] },
|
||||
{ key: "serials", label: "Webnovels, manga and manhwa", media: ["webnovel", "manga", "manhwa"] },
|
||||
{ key: "games", label: "Video games", media: ["video game"] },
|
||||
{ key: "screen", label: "Anime and TV", media: ["anime", "TV"] },
|
||||
{ key: "movies", label: "Movies", media: ["movie"] },
|
||||
] as const;
|
||||
export type MediaKey = (typeof mediaGroups)[number]["key"];
|
||||
|
||||
export const mediaGroupOf = (item: Item): MediaKey | undefined =>
|
||||
mediaGroups.find((g) => (g.media as readonly string[]).includes(item.data.medium))?.key;
|
||||
|
||||
/** The short medium label on the right of a row. */
|
||||
export const mediumLabel = (medium: string) => (medium === "video game" ? "Game" : medium === "TV" ? "TV" : medium);
|
||||
|
||||
/** How Finished is ordered: favorites, then items with a verdict, then A to Z. */
|
||||
export function byStanding(a: Item, b: Item): number {
|
||||
return (
|
||||
Number(!!b.data.favorite) - Number(!!a.data.favorite) ||
|
||||
Number(!!b.data.verdict) - Number(!!a.data.verdict) ||
|
||||
a.title.localeCompare(b.title)
|
||||
);
|
||||
}
|
||||
|
||||
export interface LibraryFilter {
|
||||
type?: MediaKey;
|
||||
q: string;
|
||||
show: Set<SectionKey>;
|
||||
favorites: boolean;
|
||||
blog: boolean;
|
||||
}
|
||||
|
||||
export function libraryFilter(params: URLSearchParams): LibraryFilter {
|
||||
const type = mediaGroups.find((g) => g.key === params.get("type"))?.key;
|
||||
// A submitted form sends `f`; before that every group shows.
|
||||
const submitted = params.has("f");
|
||||
const show = new Set<SectionKey>(
|
||||
submitted ? (params.getAll("show").filter((v) => sections.some((s) => s.key === v)) as SectionKey[]) : sections.map((s) => s.key),
|
||||
);
|
||||
return {
|
||||
type,
|
||||
q: (params.get("q") ?? "").trim().toLowerCase(),
|
||||
show,
|
||||
favorites: params.get("fav") === "1",
|
||||
blog: params.get("blog") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Text the search matches against. Kept in step with the row's visible text. */
|
||||
export const searchText = (parts: (string | number | undefined)[]) =>
|
||||
parts
|
||||
.filter((p) => p !== undefined && p !== "")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
/**
|
||||
* The favorites constellation: favorites with a cover, taken in turn from each
|
||||
* medium so the strip is not all one kind.
|
||||
*/
|
||||
export function constellation(items: Item[], max = 7): Item[] {
|
||||
const favs = items.filter((i) => i.data.favorite && i.data.cover).sort(byStanding);
|
||||
const byMedium = new Map<string, Item[]>();
|
||||
for (const i of favs) byMedium.set(i.data.medium, [...(byMedium.get(i.data.medium) ?? []), i]);
|
||||
const order = mediaGroups.flatMap((g) => g.media as readonly string[]);
|
||||
const queues = [...byMedium.entries()].sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([, q]) => q);
|
||||
const out: Item[] = [];
|
||||
while (out.length < max && queues.some((q) => q.length)) {
|
||||
for (const q of queues) {
|
||||
const next = q.shift();
|
||||
if (next && out.length < max) out.push(next);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Shelf
|
||||
|
||||
export const sorts = [
|
||||
{ key: "played", label: "Last played" },
|
||||
{ key: "az", label: "A to Z" },
|
||||
{ key: "added", label: "Recently added" },
|
||||
] as const;
|
||||
export type SortKey = (typeof sorts)[number]["key"];
|
||||
|
||||
export function sortGames(games: Game[], sort: SortKey): Game[] {
|
||||
const az = (a: Game, b: Game) => a.title.localeCompare(b.title);
|
||||
const desc = (x?: string, y?: string) => (y ?? "").localeCompare(x ?? "");
|
||||
const cmp: Record<SortKey, (a: Game, b: Game) => number> = {
|
||||
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
|
||||
az,
|
||||
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
|
||||
};
|
||||
return [...games].sort(cmp[sort]);
|
||||
}
|
||||
|
||||
export interface ShelfFilter {
|
||||
q: string;
|
||||
plays: Set<string>;
|
||||
owned: boolean;
|
||||
want: boolean;
|
||||
favorites: boolean;
|
||||
sort: SortKey;
|
||||
}
|
||||
|
||||
export function shelfFilter(params: URLSearchParams): ShelfFilter {
|
||||
const submitted = params.has("f");
|
||||
return {
|
||||
q: (params.get("q") ?? "").trim().toLowerCase(),
|
||||
plays: new Set(params.getAll("plays").map((p) => p.toLowerCase())),
|
||||
owned: submitted ? params.get("owned") === "1" : true,
|
||||
want: params.get("want") === "1",
|
||||
favorites: params.get("fav") === "1",
|
||||
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owned and Want to play add games to the list (either one); with neither
|
||||
* ticked, every game shows. Favorites only and How it plays narrow it.
|
||||
*/
|
||||
export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
|
||||
const owned = !!g.data.owned;
|
||||
const want = !!g.data.wantToPlay;
|
||||
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
|
||||
if (f.favorites && !g.data.favorite) return false;
|
||||
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
|
||||
return !f.q || text.includes(f.q);
|
||||
}
|
||||
|
||||
/** Every "how it plays" value on the shelf, A to Z. */
|
||||
export const playStyles = (games: Game[]) =>
|
||||
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
/** Statuses about a board game: what is on the table now. */
|
||||
export const onTheTable = (s: SiteIndex) =>
|
||||
s
|
||||
.statuses()
|
||||
.map((status) => ({ status, game: s.byName(status.data.subject) }))
|
||||
.filter((x): x is { status: Note<"status">; game: Game } => x.game?.type === "board-game");
|
||||
Reference in New Issue
Block a user