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

@@ -24,6 +24,7 @@ import {
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { useWS } from "../hooks/useWS.ts";
@@ -73,6 +74,7 @@ export function Dump() {
const [titleError, setTitleError] = useState<string | null>(null);
const { user, token, authFetch } = useAuth();
const { byId } = useCategories();
const {
voteCounts,
myVotes,
@@ -267,6 +269,9 @@ export function Dump() {
const { dump } = dumpState;
const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate"));
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const handleTitleSave = async () => {
const trimmed = titleDraft.trim();
@@ -428,6 +433,19 @@ export function Dump() {
</span>
)}
</div>
{dumpCategories.length > 0 && (
<div className="dump-card-categories">
{dumpCategories.map((category) => (
<Link
key={category.id}
to={`/${category.slug}`}
className="dump-card-category-chip"
>
{category.name}
</Link>
))}
</div>
)}
</div>
</div>

View File

@@ -18,6 +18,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { CategorySelect } from "../components/CategorySelect.tsx";
import {
expectOk,
FormError,
@@ -286,6 +287,15 @@ function DumpEditForm({
onSaved,
}: DumpEditFormProps) {
const { authFetch } = useAuth();
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(dump.categoryIds ?? []),
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const form = useApiForm<EditValues>({
defaultValues: {
title: dump.title,
@@ -306,11 +316,14 @@ function DumpEditForm({
const isPrivate = !isPublic;
let res: Response;
const categoryIds = [...selectedCategoryIds];
if (dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
formData.append("title", trimmedTitle);
if (comment.trim()) formData.append("comment", comment.trim());
formData.append("categoryIds", JSON.stringify(categoryIds));
res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, {
method: "PUT",
body: formData,
@@ -322,11 +335,13 @@ function DumpEditForm({
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
categoryIds,
}
: {
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
categoryIds,
};
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
@@ -428,6 +443,11 @@ function DumpEditForm({
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
/>
<CategorySelect
selected={selectedCategoryIds}
onToggle={toggleCategory}
/>
<VisibilityToggle<EditValues> name="isPublic" />
<div className="form-actions">

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}

View File

@@ -37,6 +37,7 @@ import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { CategoryManager } from "../components/CategoryManager.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts";
@@ -227,9 +228,16 @@ export function UserPublicProfile() {
// `settings` is own-profile only; `manage` requires the user:manage permission
// on someone else's profile. Invalid tabs fall back to "dumps".
const canManageUsers = can(me, "user:manage");
const canManageCategories = can(me, "category:manage");
// The "manage" tab is an admin/moderation area. On someone else's profile it
// holds per-user tools (role management); on your own it holds global tools
// (category management, with more to come).
const showManageTab = isOwnProfile
? canManageCategories
: canManageUsers;
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
if (t === "settings") return isOwnProfile;
if (t === "manage") return !isOwnProfile && canManageUsers;
if (t === "manage") return showManageTab;
return true;
});
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
@@ -780,7 +788,10 @@ export function UserPublicProfile() {
</div>
<div className="profile-info">
<div className="profile-info-scroll">
<h1 className="profile-username">{profileUser.username}</h1>
<div className="profile-username-row">
<h1 className="profile-username">{profileUser.username}</h1>
<RoleChip role={profileUser.role} />
</div>
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
@@ -893,7 +904,7 @@ export function UserPublicProfile() {
...(isOwnProfile
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
: []),
...(!isOwnProfile && canManageUsers
...(showManageTab
? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
: []),
]}
@@ -1263,6 +1274,15 @@ export function UserPublicProfile() {
</div>
</section>
)}
{tab === "manage" && isOwnProfile && canManageCategories && (
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Categories</Trans>
</h2>
<CategoryManager />
</section>
)}
</PageShell>
);
}
@@ -1529,6 +1549,14 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
{ value: "admin", label: <Trans>Admin</Trans> },
];
// Role badge shown next to the username. The default "user" role is implicit,
// so only elevated roles get a chip to avoid labelling every profile.
function RoleChip({ role }: { role: Role }) {
if (role === "user") return null;
const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
return <span className={`profile-role-chip profile-role-chip--${role}`}>{label}</span>;
}
interface RoleSwitcherProps {
userId: string;
currentRole: Role;

View File

@@ -34,6 +34,7 @@ type FeedState =
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
categorySlug?: string;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
@@ -122,6 +123,7 @@ function FollowedSubFeed({
// ── FollowedFeed ──────────────────────────────────────────────────────────────
export function FollowedFeed({
categorySlug,
voteCounts,
myVotes,
user,
@@ -131,14 +133,20 @@ export function FollowedFeed({
}: 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:${user.id}`,
`feed:followed-users:${scope}:${user.id}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
`feed:followed-playlists:${user.id}`,
`feed:followed-playlists:${scope}:${user.id}`,
hydrateDump,
);
@@ -192,7 +200,7 @@ export function FollowedFeed({
if (!token) return;
if (usersState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
@@ -212,7 +220,7 @@ export function FollowedFeed({
}
if (playlistsState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
@@ -267,7 +275,7 @@ export function FollowedFeed({
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
@@ -302,7 +310,7 @@ export function FollowedFeed({
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())