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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user