v3: added site-wide categories, added admin category management, various visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s

This commit is contained in:
khannurien
2026-06-28 16:23:44 +00:00
parent c8c7b05c25
commit fae25f3e6c
44 changed files with 2144 additions and 399 deletions

View File

@@ -6,13 +6,12 @@ import {
useRef,
useState,
} from "react";
import { useLocation } from "react-router";
import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
import { useTabParam } from "../hooks/useTabParam.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -31,6 +30,7 @@ import { useScrollSave } from "../hooks/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
@@ -38,6 +38,7 @@ import { HotFeed } from "./index/HotFeed.tsx";
import { NewFeed } from "./index/NewFeed.tsx";
import { JournalFeed } from "./index/JournalFeed.tsx";
import { FollowedFeed } from "./index/FollowedFeed.tsx";
import { NotFound } from "./NotFound.tsx";
import type { MainFeedProps } from "./index/types.ts";
type DumpsState =
@@ -51,13 +52,16 @@ type DumpsState =
loadingMore: boolean;
};
export function Index() {
/**
* The shared feed page. `categorySlug` scopes it to a single category; when
* absent it shows every dump. The router keys this component by scope, so each
* category gets its own mount/fetch while switching feed tabs reuses it.
*/
export function Index({ categorySlug }: { categorySlug?: string }) {
const location = useLocation();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
useDocumentTitle(null);
const { user, token } = useAuth();
const {
voteCounts,
@@ -68,10 +72,42 @@ export function Index() {
removeVote,
} = useWS();
// Resolve the scope to a real category (for its id, name, and 404 handling).
const { categories, isLoaded: categoriesLoaded } = useCategories();
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
: undefined;
const categoryNotFound = !!categorySlug && categoriesLoaded && !category;
useDocumentTitle(
categorySlug ? (category ? category.name : undefined) : null,
);
// ── Active feed tab (from the URL) ──
const { feedTab: rawTab } = useParams();
const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot"
: preferredTab;
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "")
? (rawTab as FeedTab)
: defaultTab;
// ── Main feed state ──
const feedUrl = useCallback(
(page: number) =>
categorySlug
? `${API_URL}/api/categories/${
encodeURIComponent(categorySlug)
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}`
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`,
[categorySlug],
);
const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${user?.id ?? "guest"}`,
`feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`,
hydrateDump,
);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
@@ -87,14 +123,6 @@ export function Index() {
);
const mainFetchDone = useRef(false);
// The preferred default tab decides which feed `/` opens on. "followed"
// requires a session, so fall back to "hot" for guests.
const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot"
: preferredTab;
const [tab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
// Web Share Target: Android share sheet navigates to /?share_url=...
const searchParams = new URLSearchParams(location.search);
const shareUrl = searchParams.get("share_url") ??
@@ -102,9 +130,6 @@ export function Index() {
useEffect(() => {
if (!shareUrl) return;
// Clean share params from the URL so a refresh doesn't re-open the modal.
// The active tab already lives in the pathname (e.g. /~/new), so just drop
// the query string.
globalThis.history.replaceState({}, "", location.pathname);
}, [shareUrl, location.pathname]);
@@ -116,13 +141,10 @@ export function Index() {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(
`${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
const res = await fetch(feedUrl(1), {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const { items, hasMore } = body.data as PaginatedData<RawDump>;
@@ -142,7 +164,7 @@ export function Index() {
mainFetchDone.current = false;
controller.abort();
};
}, [cached, token]);
}, [cached, token, feedUrl]);
// ── WS sync for main feed ──
@@ -166,7 +188,7 @@ export function Index() {
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
fetch(feedUrl(nextPage), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
@@ -189,7 +211,7 @@ export function Index() {
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [dumpsState, token]);
}, [dumpsState, token, feedUrl]);
// ── Scroll save / restore ──
@@ -227,13 +249,20 @@ export function Index() {
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
// New dumps arriving over the WS only belong in this feed if they're in scope.
const categoryId = category?.id;
const inScope = useCallback(
(d: Dump) =>
categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true,
[categorySlug, categoryId],
);
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
const combined = useMemo(
() =>
[...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter(
(d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId,
),
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId],
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId),
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
);
const mainFeedProps: MainFeedProps = {
@@ -252,6 +281,8 @@ export function Index() {
// ── Render ──
if (categoryNotFound) return <NotFound />;
return (
<div className="index-page">
<AppHeader
@@ -275,6 +306,7 @@ export function Index() {
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "followed" && user && (
<FollowedFeed
categorySlug={categorySlug}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}