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>
123 lines
5.1 KiB
TypeScript
123 lines
5.1 KiB
TypeScript
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 }));
|
||
}
|