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,385 @@
import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../dump/DumpCard.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
import { useScrollSave } from "../../hooks/feed/useScrollSave.ts";
import {
deserializeDump,
type Dump,
hydrateDump,
type PaginatedData,
type RawDump,
type User,
} from "../../model.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
type FeedState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
/** Page of a followed feed. Throws the server's own message on an error
* envelope, so it isn't mistaken for a lost connection. */
async function readPage(res: Response): Promise<PaginatedData<RawDump>> {
const body = await res.json();
if (!body.success) {
throw new Error(body.error?.message ?? `HTTP ${res.status}`);
}
return body.data as PaginatedData<RawDump>;
}
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
categorySlug?: string;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>;
}
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: FeedState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>;
emptyMessage: string;
onLoadMore: () => void;
}
function FollowedSubFeed({
state,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
const enabled = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (state.status === "error") {
return <ErrorCard title={t`Failed to load`} message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
if (visible.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
return (
<>
<ul className="dump-feed">
{visible.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote
castVote={castVote}
removeVote={removeVote}
isOwner={user.id === dump.userId}
/>
))}
</ul>
{state.hasMore && <div ref={sentinelRef} />}
{state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!state.hasMore && state.dumps.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}
// ── FollowedFeed ──────────────────────────────────────────────────────────────
export function FollowedFeed({
categorySlug,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
}: FollowedFeedProps) {
const { token } = useAuth();
// Scope key + query suffix applied to every followed-feed request.
const scope = categorySlug ?? "all";
const catQuery = categorySlug
? `&category=${encodeURIComponent(categorySlug)}`
: "";
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
`feed:followed-users:${scope}:${user.id}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
`feed:followed-playlists:${scope}:${user.id}`,
hydrateDump,
);
const [usersState, setUsersState] = useState<FeedState>(() =>
cachedUsers
? {
status: "loaded",
dumps: cachedUsers.items,
hasMore: cachedUsers.hasMore,
page: cachedUsers.page,
loadingMore: false,
}
: { status: "loading" }
);
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
cachedPlaylists
? {
status: "loaded",
dumps: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
}
: { status: "loading" }
);
const [section, setSection] = useState<FollowedSection>("users");
// WS sync
const setUsersItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setUsersState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setUsersItems);
const setPlaylistsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setPlaylistsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setPlaylistsItems);
// Fetch on mount if not cached
useEffect(() => {
if (!token) return;
if (usersState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setUsersState({ status: "error", error: friendlyFetchError(err) })
);
}
if (playlistsState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setPlaylistsState({
status: "error",
error: friendlyFetchError(err),
})
);
}
}, [token, usersState.status, playlistsState.status, catQuery]);
// Scroll save
useScrollSave(
usersState.status === "loaded",
useCallback((y) => {
if (usersState.status !== "loaded") return;
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
}, [usersState, saveUsers]),
);
useScrollSave(
playlistsState.status === "loaded",
useCallback((y) => {
if (playlistsState.status !== "loaded") return;
savePlaylists(
playlistsState.dumps,
playlistsState.page,
playlistsState.hasMore,
y,
);
}, [playlistsState, savePlaylists]),
);
// Load-more callbacks
const loadMoreUsers = useCallback(() => {
if (
usersState.status !== "loaded" || !usersState.hasMore ||
usersState.loadingMore || !token
) return;
const nextPage = usersState.page + 1;
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [usersState, token, catQuery]);
const loadMorePlaylists = useCallback(() => {
if (
playlistsState.status !== "loaded" || !playlistsState.hasMore ||
playlistsState.loadingMore || !token
) return;
const nextPage = playlistsState.page + 1;
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [playlistsState, token, catQuery]);
return (
<div className="followed-feed">
<TabBar
tabs={[
{ key: "users", label: <Trans>From people</Trans> },
{ key: "playlists", label: <Trans>From playlists</Trans> },
]}
activeTab={section}
onChange={setSection}
innerClassName="followed-sub-nav"
/>
{section === "users" && (
<FollowedSubFeed
state={usersState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage={t`Follow some users to see their dumps here.`}
onLoadMore={loadMoreUsers}
/>
)}
{section === "playlists" && (
<FollowedSubFeed
state={playlistsState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage={t`Follow some public playlists to see their dumps here.`}
onLoadMore={loadMorePlaylists}
/>
)}
</div>
);
}