mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
151bed230a
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
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
/**
|
||
* 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";
|
||
import { formatDate } 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)
|
||
);
|
||
}
|
||
|
||
/** Progress PersonalReader syncs onto a linked item (see withSynced in content.ts). */
|
||
interface ReaderSynced {
|
||
chapterProgress?: { chapter?: number; readAt?: string };
|
||
bookProgress?: { percent?: number; readAt?: string };
|
||
}
|
||
|
||
/** "Chapter 42, read Oct 3, 2026" or "62% read", from PersonalReader; undefined when nothing is synced. */
|
||
export function readerProgress(item: Item): string | undefined {
|
||
const d = item.data as ReaderSynced;
|
||
const c = d.chapterProgress;
|
||
const b = d.bookProgress;
|
||
const what = c?.chapter ? `Chapter ${c.chapter}` : b && typeof b.percent === "number" && b.percent > 0 ? `${b.percent}% read` : undefined;
|
||
if (!what) return undefined;
|
||
const at = (c?.chapter ? c.readAt : b?.readAt)?.slice(0, 10);
|
||
return at ? `${what}, read ${formatDate(at)}` : what;
|
||
}
|
||
|
||
/** The row's details line. The note's own `progress` wins over the synced one. */
|
||
export const itemDetails = (item: Item): (string | number)[] => {
|
||
const d = item.data;
|
||
return [d.creator, d.year, d.platform, d.progress || readerProgress(item), d.group].filter((x): x is string | number => x !== undefined && x !== "");
|
||
};
|
||
|
||
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" },
|
||
{ key: "time", label: "Play time" },
|
||
] 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),
|
||
// 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]);
|
||
}
|
||
|
||
export interface ShelfFilter {
|
||
q: string;
|
||
plays: Set<string>;
|
||
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 {
|
||
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",
|
||
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, 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, 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);
|
||
}
|
||
|
||
/** Values BoardGameGeek syncs onto a game (see withSynced in content.ts). */
|
||
interface GameSynced {
|
||
minPlayers?: number;
|
||
maxPlayers?: number;
|
||
playTime?: number;
|
||
minPlayTime?: number;
|
||
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. */
|
||
export function gamePlayers(g: Game): string | undefined {
|
||
const d = g.data as GameSynced;
|
||
const r = range(d.minPlayers, d.maxPlayers);
|
||
return r ? `${r} ${r === "1" ? "player" : "players"}` : undefined;
|
||
}
|
||
|
||
/** "60–90 min", "45 min" or undefined. */
|
||
export function gamePlayTime(g: Game): string | undefined {
|
||
const d = g.data as GameSynced;
|
||
const r = d.minPlayTime && d.maxPlayTime ? range(d.minPlayTime, d.maxPlayTime) : range(d.playTime);
|
||
return r ? `${r} min` : undefined;
|
||
}
|
||
|
||
/** 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");
|