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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user