Files
mangabaka-seeder/src/web/view-model.ts
T
Lucas WintherandClaude Opus 5.5 17ecebc4a4 feat(web): Obi Shelf design in a console layout, with a timeline view; record DESIGN.md
The user chose the direction over two mockup rounds (samples/mockups, gitignored):
the Obi Shelf's shelf and colours, Graphite Console side panels, the Annotated
Edition timeline as a second view, and warm off-colour grounds in both themes.
Selects look editable; labels are source-neutral. impeccable finish review: ship.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:38:00 +02:00

123 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatMonth } from "../core/covers";
import type { HostId, HostOptions } from "../core/hosting/host";
import { HOSTS } from "../core/hosting/registry";
import type { Granularity, InspectedCover } from "../core/model";
import type { Stage } from "../core/pipeline";
import type { ReviewCard } from "../core/session";
export interface ServerConfig {
storage: boolean;
storageFull: boolean;
}
export interface HostChoice {
id: HostId;
label: string;
lifetime: string;
/** Why it can't be used right now, or null. */
disabled: string | null;
warning: string | null;
}
/** Spec §4.6 / §4.10: server storage only when configured; a full store is shown disabled with the reason. */
export function hostChoices(config: ServerConfig | null, opts: HostOptions, front: { catboxNeedsServer: boolean }): HostChoice[] {
const choices: HostChoice[] = [];
if (config?.storage) {
choices.push({
id: "server", label: "This server", lifetime: HOSTS.server.lifetime(opts),
disabled: config.storageFull ? "Storage is full until older covers expire. Use litterbox or catbox." : null, warning: null,
});
}
choices.push(
{
id: "litterbox", label: "litterbox", lifetime: HOSTS.litterbox.lifetime(opts), disabled: null,
warning: "A MangaBaka review can take longer than this; the links then stop working.",
},
{
id: "catbox", label: "catbox.moe", lifetime: HOSTS.catbox.lifetime(opts),
disabled: front.catboxNeedsServer && !config ? "Needs this site's server, which isn't answering." : null, warning: null,
},
{
id: "direct", label: "No rehosting", lifetime: HOSTS.direct.lifetime(opts), disabled: null,
warning: "Submits WEBTOON's own image URL. Covers only on archive.org can't use this.",
},
);
return choices;
}
export const defaultHost = (choices: readonly HostChoice[]): HostId | null => choices.find((c) => !c.disabled)?.id ?? null;
const STAGE_TEXT: Readonly<Record<Stage, string>> = {
series: "Reading the MangaBaka series",
source: "Reading the source page",
history: "Looking through the Wayback Machine",
inspect: "Checking images",
compare: "Comparing with MangaBaka",
};
export function stageText(stage: Stage, done?: number, total?: number): string {
return total ? `${STAGE_TEXT[stage]}… ${done ?? 0}/${total}` : `${STAGE_TEXT[stage]}…`;
}
export const GRANULARITY_LABELS: Readonly<Record<Granularity, string>> = {
1: "every month", 3: "every 3 months", 6: "every 6 months", 12: "every year",
};
/** The evidence shown on each card (PRODUCT.md: show where and when each cover was seen). */
export function cardFacts(card: ReviewCard): string[] {
const c = card.cover;
const facts = [`${c.width}×${c.height}`, `${Math.max(1, Math.round(c.bytes.byteLength / 1024))} KB`];
if (c.shape === "tall") facts.push("landing-page art");
if (c.candidate.current) {
facts.push("on the page now");
} else {
const from = formatMonth(c.candidate.seenAt ?? "");
const to = formatMonth(c.candidate.lastSeenAt ?? c.candidate.seenAt ?? "");
facts.push(from === to ? `seen ${from}` : `seen ${from} – ${to}`);
}
if (!c.live) facts.push("only on archive.org");
return facts;
}
/** Months since year 0, from an archive timestamp (YYYYMM…) or a date. */
const monthOf = (ts: string) => Number(ts.slice(0, 4)) * 12 + Number(ts.slice(4, 6)) - 1;
const monthNow = (now: Date) => now.getUTCFullYear() * 12 + now.getUTCMonth();
export interface Span {
from: number;
to: number;
}
/** From the earliest sighting of any cover to now, in months. */
export function seenSpan(covers: readonly InspectedCover[], now = new Date()): Span {
const to = monthNow(now);
const starts = covers.flatMap((c) => (c.candidate.seenAt ? [monthOf(c.candidate.seenAt)] : []));
return { from: Math.min(to, ...starts), to };
}
function rangeOf(c: InspectedCover, now: Date): Span {
const to = monthNow(now);
const { seenAt, lastSeenAt, current } = c.candidate;
const start = seenAt ? monthOf(seenAt) : to;
return { from: start, to: current ? to : lastSeenAt ? monthOf(lastSeenAt) : start };
}
/** Where a cover's seen-period sits on the series' history, in percent, drawn to scale. */
export function seenBar(c: InspectedCover, span: Span, now = new Date()): { left: number; width: number } {
const total = Math.max(1, span.to - span.from + 1);
const r = rangeOf(c, now);
const left = ((r.from - span.from) / total) * 100;
return { left, width: Math.max(1.5, Math.min(100 - left, ((r.to - r.from + 1) / total) * 100)) };
}
/** Timeline view: covers grouped by the year they were last seen (now, for current ones), newest first. */
export function timelineGroups(cards: readonly ReviewCard[], now = new Date()): { year: number; cards: ReviewCard[] }[] {
const groups = new Map<number, ReviewCard[]>();
const sorted = [...cards].sort((a, b) => rangeOf(b.cover, now).to - rangeOf(a.cover, now).to);
for (const card of sorted) {
const year = Math.floor(rangeOf(card.cover, now).to / 12);
groups.set(year, [...(groups.get(year) ?? []), card]);
}
return [...groups].map(([year, list]) => ({ year, cards: list }));
}