v3: code quality pass

This commit is contained in:
khannurien
2026-03-24 18:47:05 +00:00
parent cd4076343b
commit c293f3e706
39 changed files with 1464 additions and 1555 deletions

View File

@@ -0,0 +1,24 @@
import { useEffect } from "react";
/**
* Sets up a debounced scroll listener that calls `onSave(scrollY)` when the
* user scrolls, but only while `enabled` is true (e.g. the page is loaded).
*/
export function useScrollSave(
enabled: boolean,
onSave: (scrollY: number) => void,
) {
useEffect(() => {
if (!enabled) return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => onSave(globalThis.scrollY), 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [enabled, onSave]);
}

View File

@@ -0,0 +1,191 @@
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { useAuth } from "./useAuth.ts";
import { useFeedCache } from "./useFeedCache.ts";
import { useInfiniteScroll } from "./useInfiniteScroll.ts";
import { useScrollSave } from "./useScrollSave.ts";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
items: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
interface UseUserDumpFeedOptions {
/** Called with newly appended items whenever a loadMore succeeds. */
onItemsAppended?: (items: Dump[]) => void;
}
interface UseUserDumpFeedResult {
state: State;
setState: React.Dispatch<React.SetStateAction<State>>;
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
sentinelRef: RefObject<HTMLDivElement | null>;
}
/**
* Shared data-fetching, pagination, and scroll-save logic for profile subpages
* that display a paginated list of dumps (e.g. UserDumps, UserUpvoted).
*
* @param username The route param value (may be undefined during hydration)
* @param endpoint Relative path after /api/users/:username, e.g. "dumps" or "votes"
* @param cacheKey sessionStorage key for the feed cache
* @param options Optional callbacks
*/
export function useUserDumpFeed(
username: string | undefined,
endpoint: string,
cacheKey: string,
options?: UseUserDumpFeedOptions,
): UseUserDumpFeedResult {
const { token } = useAuth();
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
const [state, setState] = useState<State>({ status: "loading" });
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
}, []);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
if (cached) {
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
items: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, itemsRes]) =>
Promise.all([userRes.json(), itemsRes.json()])
)
.then(([userBody, itemsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = itemsBody.success
? itemsBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
items: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, endpoint]);
const { onItemsAppended } = options ?? {};
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
? {
...s,
items: [...s.items, ...newItems],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
onItemsAppended?.(newItems);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token, endpoint, onItemsAppended]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useScrollSave(
state.status === "loaded",
useCallback((y) => {
if (state.status !== "loaded") return;
saveState(state.items, state.page, state.hasMore, y);
}, [state, saveState]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
return { state, setState, setItems, sentinelRef };
}