v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:44:14 +00:00
parent 07d0344be7
commit 2b6e68391b
242 changed files with 4390 additions and 4229 deletions

View File

@@ -0,0 +1,78 @@
import { API_URL } from "../../config/api.ts";
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
interface BandcampTrack {
index: number;
trackNum?: number;
title: string;
duration?: number;
streamUrl: string | null;
streamable: boolean;
capped: boolean;
}
interface BandcampAlbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: BandcampTrack[];
resolvedAt: number;
}
export interface BandcampContext {
dumpHref?: string;
/** Kept on every item so playback can fall back to the iframe later. */
embedUrl?: string;
/** Used only if the page carries no album art of its own. */
fallbackArtworkUrl?: string;
}
/**
* Resolve a Bandcamp page into a playable queue.
*
* Throws on any failure — callers treat that as "fall back to the embed".
* `force` skips the server's cache, for re-resolving after a signature expires.
*/
export async function resolveBandcampQueue(
pageUrl: string,
ctx: BandcampContext = {},
{ force = false }: { force?: boolean } = {},
): Promise<PlayerItem[]> {
const res = await fetch(
`${API_URL}/api/bandcamp/tracks?url=${encodeURIComponent(pageUrl)}${
force ? "&force=1" : ""
}`,
);
if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
const body = await res.json() as { success: boolean; data?: BandcampAlbum };
const album = body.data;
if (!body.success || !album) throw new Error("resolve returned no data");
const items = album.tracks
.filter((t): t is BandcampTrack & { streamUrl: string } =>
t.streamable && typeof t.streamUrl === "string"
)
.map((t): PlayerItem => ({
kind: "stream",
streamUrl: t.streamUrl,
type: "bandcamp",
// The row names the track; the artist rides along on the subtitle line.
title: t.title,
subtitle: album.artist,
duration: t.duration,
trackNum: t.trackNum,
artworkUrl: album.artworkUrl ?? ctx.fallbackArtworkUrl,
dumpHref: ctx.dumpHref,
resolveUrl: album.sourceUrl,
resolveIndex: t.index,
resolvedAt: album.resolvedAt,
embedUrl: ctx.embedUrl,
}));
if (items.length === 0) throw new Error("no streamable tracks");
return items;
}