v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,8 +65,8 @@ export function Dump(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
// Taken once: it stays in the history entry, where a reload or Back would
|
||||
// show that snapshot forever, so it is cleared from there below.
|
||||
// Taken once, since it stays in the history entry, where a reload or Back
|
||||
// would show that snapshot forever. It is cleared from there below.
|
||||
const [handedOver] = useState(
|
||||
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
|
||||
);
|
||||
|
||||
@@ -56,9 +56,9 @@ type DumpEditState =
|
||||
/**
|
||||
* Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators;
|
||||
* anyone else signed in gets an error rather than a form the API would refuse
|
||||
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
|
||||
* and the deletion apply at once, and the form's other fields on "Save", which
|
||||
* goes back to the dump's page. A deletion goes to the index.
|
||||
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the
|
||||
* preview refresh and the deletion apply at once, and the form's other fields
|
||||
* on "Save", which goes back to the dump's page. A deletion goes to the index.
|
||||
*/
|
||||
export function DumpEdit(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
|
||||
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
} from "../config/feedTabs.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
||||
import {
|
||||
useScrollRestore,
|
||||
@@ -37,11 +36,7 @@ import { useCategories } from "../hooks/category/useCategories.ts";
|
||||
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
||||
|
||||
import {
|
||||
DumpFeed,
|
||||
type DumpFeedState,
|
||||
type MainFeedProps,
|
||||
} from "../components/feed/DumpFeed.tsx";
|
||||
import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
|
||||
import { JournalFeed } from "../components/feed/JournalFeed.tsx";
|
||||
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
@@ -71,7 +66,7 @@ export function Index(
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
?.deletedDumpId;
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
|
||||
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
||||
@@ -97,31 +92,26 @@ export function Index(
|
||||
? (rawTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
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 feedUrl = categorySlug
|
||||
? `${API_URL}/api/categories/${encodeURIComponent(categorySlug)}/dumps`
|
||||
: `${API_URL}/api/dumps/`;
|
||||
const fetchPage = useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(pageUrl(feedUrl, page), { signal })
|
||||
.then((res) => readPage(res, deserializeDump)),
|
||||
[authFetch, feedUrl],
|
||||
);
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${categorySlug ?? "all"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [dumpsState, setDumpsState] = useState<DumpFeedState>(() =>
|
||||
cached
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
const { state: dumpsState, setItems, loadMore } = usePagedList(
|
||||
`${feedUrl}:${user?.id ?? "guest"}`,
|
||||
fetchPage,
|
||||
cached,
|
||||
);
|
||||
useDumpListSync(setItems);
|
||||
|
||||
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
|
||||
// the redirect from `/` keeps. The query is dropped from the URL at once, so
|
||||
@@ -135,42 +125,6 @@ export function Index(
|
||||
globalThis.history.replaceState({}, "", location.pathname);
|
||||
}, [shareUrl, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(feedUrl(1), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const { items, hasMore } = await expectOk<
|
||||
PaginatedData<Wire<Dump>>
|
||||
>(res);
|
||||
setDumpsState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setDumpsState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [cached, token, feedUrl]);
|
||||
|
||||
const setDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setDumpsState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
useDumpListSync(setDumpsItems);
|
||||
|
||||
// Dumps posted while the socket was down never arrived. On reconnect, the
|
||||
// first page is fetched again and whatever is new goes on top, keeping the
|
||||
// pages loaded and the scroll position.
|
||||
@@ -179,76 +133,34 @@ export function Index(
|
||||
if (connectionEpoch === backfillEpochRef.current) return;
|
||||
backfillEpochRef.current = connectionEpoch;
|
||||
const controller = new AbortController();
|
||||
fetch(feedUrl(1), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items }) => {
|
||||
const fresh = items.map(deserializeDump);
|
||||
setDumpsState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const known = new Set(s.dumps.map((d) => d.id));
|
||||
const newOnes = fresh.filter((d) => !known.has(d.id));
|
||||
if (newOnes.length === 0) return s;
|
||||
return { ...s, dumps: [...newOnes, ...s.dumps] };
|
||||
});
|
||||
})
|
||||
fetchPage(1, controller.signal)
|
||||
.then(({ items }) =>
|
||||
setItems((dumps) => {
|
||||
const known = new Set(dumps.map((d) => d.id));
|
||||
const newOnes = items.filter((d) => !known.has(d.id));
|
||||
return newOnes.length === 0 ? dumps : [...newOnes, ...dumps];
|
||||
})
|
||||
)
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [connectionEpoch, feedUrl, token]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||
dumpsState.loadingMore
|
||||
) return;
|
||||
const nextPage = dumpsState.page + 1;
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(feedUrl(nextPage), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [dumpsState, token, feedUrl]);
|
||||
}, [connectionEpoch, fetchPage, setItems]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||
!dumpsState.loadingMore,
|
||||
dumpsState.status === "loaded" && canLoadMore(dumpsState.data),
|
||||
);
|
||||
|
||||
useScrollSave(
|
||||
dumpsState.status === "loaded",
|
||||
useCallback((y) => {
|
||||
if (dumpsState.status !== "loaded") return;
|
||||
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
|
||||
if (dumpsState.status === "loaded") saveState(dumpsState.data, y);
|
||||
}, [dumpsState, saveState]),
|
||||
);
|
||||
|
||||
useScrollRestore(cached?.scrollY, dumpsState.status === "loaded");
|
||||
|
||||
const dumps = useMemo(
|
||||
() => dumpsState.status === "loaded" ? dumpsState.dumps : [],
|
||||
() => dumpsState.status === "loaded" ? dumpsState.data.items : [],
|
||||
[dumpsState],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -31,23 +31,10 @@ import type {
|
||||
UserFollowedData,
|
||||
UserMentionedData,
|
||||
} from "../../shared/notification.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
import { deserializeNotification } from "../utils/shared/deserialize.ts";
|
||||
import { pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
|
||||
type State =
|
||||
// `arrived` holds what the socket sent before the list came back, which the
|
||||
// list may predate.
|
||||
| { status: "loading"; arrived: Notification[] }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
items: Notification[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type NotifIconKind =
|
||||
| "upvote"
|
||||
@@ -293,94 +280,41 @@ function groupByDate(
|
||||
*/
|
||||
export function Notifications(): React.JSX.Element {
|
||||
useDocumentTitle(t`Notifications`);
|
||||
const { authFetch } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { clearUnreadNotifications, lastNotification } = useWS();
|
||||
const [state, setState] = useState<State>({
|
||||
status: "loading",
|
||||
arrived: [],
|
||||
});
|
||||
const { state, loadMore } = usePagedList(
|
||||
user?.id,
|
||||
(page, signal) =>
|
||||
authFetch(
|
||||
pageUrl(`${API_URL}/api/notifications`, page, NOTIFICATIONS_PAGE_SIZE),
|
||||
{ signal },
|
||||
).then((res) => readPage(res, deserializeNotification)),
|
||||
);
|
||||
|
||||
// The latest notification the read-all requests have covered, so that one
|
||||
// arriving while the page is open is marked read once, and the one the bell
|
||||
// already had isn't marked again after the first load.
|
||||
const lastNotificationRef = useRef(lastNotification);
|
||||
const markedReadRef = useRef(lastNotification);
|
||||
useLayoutEffect(() => {
|
||||
lastNotificationRef.current = lastNotification;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await expectOk<PaginatedData<Wire<Notification>>>(
|
||||
await authFetch(
|
||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const items = data.items.map(deserializeNotification);
|
||||
setState((s) => ({
|
||||
status: "loaded",
|
||||
items: [
|
||||
...(s.status === "loading" ? s.arrived : []).filter((a) =>
|
||||
!items.some((n) => n.id === a.id)
|
||||
),
|
||||
...items,
|
||||
],
|
||||
hasMore: data.hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If this fails, the list is still worth showing, and they stay unread
|
||||
// for next time.
|
||||
markedReadRef.current = lastNotificationRef.current;
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (cancelled || !res.ok) return;
|
||||
// Only the bell clears. Marking the list read too would take its dots
|
||||
// and "new" count away a moment after they appear.
|
||||
clearUnreadNotifications();
|
||||
} catch {
|
||||
// Offline: they stay unread for next time.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authFetch, clearUnreadNotifications]);
|
||||
|
||||
// Each new `lastNotification` is added once, during render rather than in an
|
||||
// effect, so it shows in the same frame.
|
||||
// Each new `lastNotification` is kept once, during render rather than in an
|
||||
// effect, so it shows in the same frame. The list may predate it or not.
|
||||
const [arrived, setArrived] = useState<Notification[]>([]);
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
lastNotification,
|
||||
);
|
||||
if (prevLastNotification !== lastNotification && lastNotification !== null) {
|
||||
setPrevLastNotification(lastNotification);
|
||||
setState((s) => {
|
||||
if (s.status === "loading") {
|
||||
return { ...s, arrived: [lastNotification, ...s.arrived] };
|
||||
}
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.items.some((n) => n.id === lastNotification.id)) return s;
|
||||
return { ...s, items: [lastNotification, ...s.items] };
|
||||
});
|
||||
setArrived((a) => [lastNotification, ...a]);
|
||||
}
|
||||
const items = useMemo(() => {
|
||||
if (state.status !== "loaded") return [];
|
||||
const listed = new Set(state.data.items.map((n) => n.id));
|
||||
return [...arrived.filter((n) => !listed.has(n.id)), ...state.data.items];
|
||||
}, [state, arrived]);
|
||||
|
||||
// One that arrives while the page is open is being seen: it stays
|
||||
// highlighted here, but the bell must not count it.
|
||||
// Every notification is marked read on the server once the list shows, and
|
||||
// again for each that arrives while the page is open. Only the bell clears:
|
||||
// clearing the list too would take its dots and "new" count away a moment
|
||||
// after they appear. A failure leaves them unread for next time.
|
||||
const loaded = state.status === "loaded";
|
||||
const markedReadRef = useRef<Notification | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
if (markedReadRef.current === lastNotification) return;
|
||||
if (!loaded || markedReadRef.current === lastNotification) return;
|
||||
markedReadRef.current = lastNotification;
|
||||
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
|
||||
.then((res) => {
|
||||
@@ -389,39 +323,7 @@ export function Notifications(): React.JSX.Element {
|
||||
.catch(() => {});
|
||||
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||
return;
|
||||
}
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Notification>>>(res))
|
||||
.then((data) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
items: [...s.items, ...data.items.map(deserializeNotification)],
|
||||
hasMore: data.hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const totalUnread = state.status === "loaded"
|
||||
? state.items.filter((n) => !n.read).length
|
||||
: 0;
|
||||
const totalUnread = items.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -447,7 +349,7 @@ export function Notifications(): React.JSX.Element {
|
||||
<ErrorCard title={t`Failed to load`} message={state.error} />
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.items.length === 0 && (
|
||||
{state.status === "loaded" && items.length === 0 && (
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon">🔕</span>
|
||||
<p>
|
||||
@@ -462,8 +364,8 @@ export function Notifications(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.items.length > 0 &&
|
||||
groupByDate(state.items).map(({ label, items }) => (
|
||||
{state.status === "loaded" && items.length > 0 &&
|
||||
groupByDate(items).map(({ label, items }) => (
|
||||
<section key={label} className="notif-group">
|
||||
<h2 className="notif-group-label">
|
||||
{label === "Today"
|
||||
@@ -508,8 +410,11 @@ export function Notifications(): React.JSX.Element {
|
||||
</section>
|
||||
))}
|
||||
|
||||
{state.status === "loaded" && state.hasMore && (
|
||||
<LoadMoreButton loadingMore={state.loadingMore} onClick={loadMore} />
|
||||
{state.status === "loaded" && state.data.hasMore && (
|
||||
<LoadMoreButton
|
||||
loadingMore={state.data.loadingMore}
|
||||
onClick={loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
@@ -5,557 +5,57 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { CountedInput } from "../components/shared/CountedInput.tsx";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
ReorderPlaylistRequest,
|
||||
UpdatePlaylistRequest,
|
||||
} from "../../shared/playlist.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylist,
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../utils/shared/urls.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
||||
import { fadeClassName, useFading } from "../hooks/feed/useFading.ts";
|
||||
import { useWS } from "../hooks/ws/useWS.ts";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { usePlaylist } from "../hooks/playlist/usePlaylist.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { TextEditor } from "../components/editor/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { VisibilitySegmented } from "../components/form/SegmentedField.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { FormError } from "../components/form/FormError.tsx";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; playlist: PlaylistWithDumps };
|
||||
import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
|
||||
import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
|
||||
import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx";
|
||||
|
||||
/**
|
||||
* Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see
|
||||
* the playlist: a private one only to its owner. The playlist and the dumps
|
||||
* the viewer may see in it come from `GET /api/playlists/:playlistId`, and the
|
||||
* socket keeps them live. Its owner, or a user with `playlist:moderate`, can
|
||||
* edit or delete it, drag its dumps into a new order, and remove one, with a
|
||||
* short window to undo.
|
||||
* the playlist: a private one only to its owner. `usePlaylist` loads it and
|
||||
* keeps it live. Its owner, or a user with `playlist:moderate`, can edit or
|
||||
* delete it, drag its dumps into a new order, and remove one, with a short
|
||||
* window to undo.
|
||||
*/
|
||||
export function PlaylistDetail(): React.JSX.Element {
|
||||
const { playlistId } = useParams<{ playlistId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch, token, mediaToken } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
// The socket names playlists by id, while the URL may hold the slug.
|
||||
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||
|
||||
useDocumentTitle(
|
||||
state.status === "loaded"
|
||||
? state.playlist.title
|
||||
: state.status === "error"
|
||||
? null
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// The dumps in the playlist. `state.playlist.dumps` also keeps the removed
|
||||
// ones until their fade ends.
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// A removed dump shows faded for 2 seconds, while it can be undone, then
|
||||
// slides out, as on the upvoted page, and leaves `state.playlist.dumps`.
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading(
|
||||
(id) =>
|
||||
setState((prev) =>
|
||||
prev.status !== "loaded" ? prev : {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
|
||||
},
|
||||
}
|
||||
),
|
||||
);
|
||||
// An undo re-adds the dump, which the server puts first, then restores its
|
||||
// place. This holds that place's order until both requests are done, so the
|
||||
// socket's echo of the first doesn't flash the dump at the top.
|
||||
const pendingUndoOrderRef = useRef<string[] | null>(null);
|
||||
// Other clients see the same two echoes without that order, so a new order
|
||||
// from the socket is applied only once none follows for 80 ms.
|
||||
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
state,
|
||||
fading,
|
||||
actionError,
|
||||
applyDetails,
|
||||
remove,
|
||||
undoRemove,
|
||||
move,
|
||||
saveOrder,
|
||||
deletePlaylist,
|
||||
} = usePlaylist(playlistId);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// A delete or a removal the server refused. A refused reorder just reloads.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// For the socket effects, which must not re-run when it changes.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// Every dump of the playlist seen so far, so that one hidden when it turned
|
||||
// private comes back when it turns public. A dump removed from the playlist
|
||||
// leaves it.
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// The removals still in flight, by dump, which an undo must wait for.
|
||||
const removalsRef = useRef<Map<string, Promise<unknown>>>(new Map());
|
||||
// The server's last order, where such a dump comes back.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeDumpIdsRef.current = activeDumpIds;
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchPlaylist = useCallback(() => {
|
||||
if (!playlistId) return;
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
// In a microtask, since the linter forbids a synchronous setState in the
|
||||
// effect that calls this. The user sees no difference.
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
|
||||
);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load playlist");
|
||||
const pl = deserializePlaylistWithDumps(
|
||||
body.data as Wire<PlaylistWithDumps>,
|
||||
);
|
||||
setState({ status: "loaded", playlist: pl });
|
||||
const ids = new Set(pl.dumps.map((d) => d.id));
|
||||
const order = pl.dumps.map((d) => d.id);
|
||||
setActiveDumpIds(ids);
|
||||
dumpOrderRef.current = order;
|
||||
for (const id of ids) knownDumpIdsRef.current.add(id);
|
||||
cancelAll();
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
dumpReorderTimerRef.current = null;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setState({
|
||||
status: "error",
|
||||
error: friendlyFetchError(err),
|
||||
});
|
||||
});
|
||||
}, [playlistId, token, cancelAll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [fetchPlaylist]);
|
||||
|
||||
// The socket's changes to this playlist: its dumps, its details, its end.
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
if (ev.playlistId !== playlistUUID) return;
|
||||
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const newIds = new Set(ev.dumpIds);
|
||||
for (const id of newIds) knownDumpIdsRef.current.add(id);
|
||||
const prev = activeDumpIdsRef.current;
|
||||
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
// Shown until now and gone from the list: removed from the
|
||||
// playlist, not hidden (that comes as `dump_deleted`).
|
||||
knownDumpIdsRef.current.delete(id);
|
||||
setActiveDumpIds((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
startFading(id);
|
||||
}
|
||||
}
|
||||
|
||||
// An added dump is fetched alone. fetchPlaylist would go back through
|
||||
// "loading", which unsets playlistUUID and runs this effect again.
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
cancelFading(id);
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((body) => {
|
||||
if (!body?.success) return;
|
||||
const dump = deserializeDump(body.data as Wire<Dump>);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
return {
|
||||
...s,
|
||||
playlist: {
|
||||
...s.playlist,
|
||||
dumps: [
|
||||
...orderedIds
|
||||
.filter((oid) => dumpMap.has(oid))
|
||||
.map((oid) => dumpMap.get(oid)!),
|
||||
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced: see dumpReorderTimerRef.
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
}
|
||||
const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
|
||||
const serverOrder = ev.dumpIds!;
|
||||
dumpReorderTimerRef.current = setTimeout(() => {
|
||||
dumpReorderTimerRef.current = null;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
const orderedActive = orderToApply
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!);
|
||||
let ai = 0;
|
||||
// The fading dumps keep their slots, the others take the new order,
|
||||
// and dumps whose fetch landed late go last.
|
||||
const merged = s.playlist.dumps.map((d) =>
|
||||
newIds.has(d.id) ? orderedActive[ai++] : d
|
||||
);
|
||||
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
|
||||
return { ...s, playlist: { ...s.playlist, dumps: merged } };
|
||||
});
|
||||
dumpOrderRef.current = serverOrder;
|
||||
}, 80);
|
||||
} else if (ev.type === "updated" && ev.playlist) {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
title: ev.playlist!.title,
|
||||
description: ev.playlist!.description,
|
||||
isPublic: ev.playlist!.isPublic,
|
||||
imageMime: ev.playlist!.imageMime,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
}
|
||||
}, [
|
||||
lastPlaylistEvent,
|
||||
playlistUUID,
|
||||
navigate,
|
||||
token,
|
||||
startFading,
|
||||
cancelFading,
|
||||
]);
|
||||
|
||||
// A deleted dump leaves at once, without the removal's fade.
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
// It mirrors the socket's state, which only an effect can do.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const filtered = prev.playlist.dumps.filter((d) =>
|
||||
!deletedDumpIds.has(d.id)
|
||||
);
|
||||
if (filtered.length === prev.playlist.dumps.length) return prev;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
|
||||
});
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
for (const id of deletedDumpIds) n.delete(id);
|
||||
return n;
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
// An edited dump changes in place, and one of the playlist's dumps that
|
||||
// turned public again comes back at its place in the server's order.
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
const dump = lastDumpEvent;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||
if (idx !== -1) {
|
||||
const dumps = [...prev.playlist.dumps];
|
||||
dumps[idx] = dump;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||
}
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
const order = dumpOrderRef.current;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
const reinserted = order.length > 0
|
||||
? [
|
||||
...order.filter((id) => dumpMap.has(id)).map((id) =>
|
||||
dumpMap.get(id)!
|
||||
),
|
||||
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
|
||||
]
|
||||
: [...prev.playlist.dumps, dump];
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
setActiveDumpIds((prev) => {
|
||||
if (prev.has(dump.id)) return prev;
|
||||
return new Set([...prev, dump.id]);
|
||||
});
|
||||
}
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
dragSrcRef.current = index;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// Swapping as soon as the pointer enters a card would swap it straight
|
||||
// back, so the pointer must cross the card's middle first.
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (src < index && e.clientY < mid) return;
|
||||
if (src > index && e.clientY > mid) return;
|
||||
// The drag indices count only the dumps not fading, which go last.
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const ids = activeDumpIdsRef.current;
|
||||
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
|
||||
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
|
||||
const reordered = [...activeDumps];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
const src = dragSrcRef.current;
|
||||
dragSrcRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
if (src === null || state.status !== "loaded" || !playlistId) return;
|
||||
const activeDumps = state.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/order`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
dumpIds: activeDumps.map((d) => d.id),
|
||||
} satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
// Back to the order the server actually kept.
|
||||
fetchPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
const index = state.playlist.dumps.findIndex((d) => d.id === dumpId);
|
||||
const dump = state.playlist.dumps[index];
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
// Removed on the server at once; the undo adds it back.
|
||||
const removal = authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`,
|
||||
{ method: "DELETE" },
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
cancelFading(dumpId);
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// The fade may have taken it out of the list already.
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || !dump) return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dumpId)) return s;
|
||||
const dumps = [...s.playlist.dumps];
|
||||
dumps.splice(Math.min(index, dumps.length), 0, dump);
|
||||
return { ...s, playlist: { ...s.playlist, dumps } };
|
||||
});
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
}).finally(() => removalsRef.current.delete(dumpId));
|
||||
removalsRef.current.set(dumpId, removal);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFading(dumpId);
|
||||
};
|
||||
|
||||
const handleCancelRemove = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
cancelFading(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// A fading dump keeps its slot in `state.playlist.dumps`, so that order is
|
||||
// the one to restore.
|
||||
const restoredIds = new Set([...activeDumpIds, dumpId]);
|
||||
const desiredOrder = state.playlist.dumps
|
||||
.filter((d) => restoredIds.has(d.id))
|
||||
.map((d) => d.id);
|
||||
pendingUndoOrderRef.current = desiredOrder;
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
// Added back only once the removal has landed, or the two could cross.
|
||||
// The server adds the dump first, so the order is restored after.
|
||||
(removalsRef.current.get(dumpId) ?? Promise.resolve())
|
||||
.then(() =>
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
.then((res) => {
|
||||
// 409: the removal failed, so it never left.
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFading(dumpId);
|
||||
setActionError(t`The dump couldn't be put back. Try again.`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingUndoOrderRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = () => setEditOpen(true);
|
||||
|
||||
const handleEditSaved = (updated: Playlist) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo, which may never
|
||||
// come (disconnected); a new slug also moves the URL.
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
...updated,
|
||||
dumps: prev.playlist.dumps,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!playlistId) return;
|
||||
setConfirmDelete(false);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// expectOk throws the server's message.
|
||||
if (!res.ok) await expectOk(res);
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
};
|
||||
useDocumentTitle(
|
||||
state.status === "loaded"
|
||||
? state.data.title
|
||||
: state.status === "error"
|
||||
? null
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
@@ -584,15 +84,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { playlist } = state;
|
||||
const isOwner = !!user && user.id === playlist.userId;
|
||||
const canEdit = isOwner || can(user, "playlist:moderate");
|
||||
|
||||
// The fading dumps stay on screen, but can't be dragged.
|
||||
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
|
||||
const visibleDumps = playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id) || d.id in fading
|
||||
);
|
||||
const playlist = state.data;
|
||||
const canEdit = (!!user && user.id === playlist.userId) ||
|
||||
can(user, "playlist:moderate");
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -604,81 +98,19 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
playlist={playlist}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={handleEditSaved}
|
||||
onSaved={(updated) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo,
|
||||
// which may never come (disconnected).
|
||||
applyDetails(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={playlistImageUrl(playlist, mediaToken)}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{!isOwner && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
<span
|
||||
className={`playlist-badge${
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic
|
||||
? <Trans>public</Trans>
|
||||
: <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && (
|
||||
<Link
|
||||
to={`/users/${playlist.ownerUsername}`}
|
||||
className="playlist-detail-owner"
|
||||
>
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||
<time dateTime={playlist.createdAt.toISOString()}>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{playlist.updatedAt && (
|
||||
<Tooltip
|
||||
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
|
||||
>
|
||||
<span className="playlist-edited-label">
|
||||
<Trans>
|
||||
edited {relativeTime(playlist.updatedAt)}
|
||||
</Trans>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<PlaylistHeader
|
||||
playlist={playlist}
|
||||
onEdit={canEdit ? () => setEditOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -687,277 +119,28 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
<ErrorCard title={t`Something went wrong`} message={actionError} />
|
||||
)}
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No dumps in this playlist yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={canEdit ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{visibleDumps.map((dump) => {
|
||||
const isActive = activeDumpIds.has(dump.id);
|
||||
const phase = fading[dump.id];
|
||||
// drag index is within the active-only list
|
||||
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
|
||||
<PlaylistDumpList
|
||||
dumps={playlist.dumps}
|
||||
fading={fading}
|
||||
{...(canEdit && {
|
||||
onRemove: remove,
|
||||
onUndoRemove: undoRemove,
|
||||
onMove: move,
|
||||
onMoved: saveOrder,
|
||||
})}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dump.id}
|
||||
className={`playlist-dump-item${
|
||||
activeIndex === dragOverIndex && isActive
|
||||
? " playlist-dump-item--drag-over"
|
||||
: ""
|
||||
}`}
|
||||
draggable={canEdit && isActive}
|
||||
onDragStart={canEdit && isActive
|
||||
? () => handleDragStart(activeIndex)
|
||||
: undefined}
|
||||
onDragOver={canEdit && isActive
|
||||
? (e) => handleDragOver(e, activeIndex)
|
||||
: undefined}
|
||||
onDragEnd={canEdit ? handleDragEnd : undefined}
|
||||
>
|
||||
{canEdit && isActive && (
|
||||
<span className="drag-handle" aria-hidden>⠿</span>
|
||||
)}
|
||||
<DumpCard
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={fadeClassName(phase)}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
/>
|
||||
{canEdit && (isActive
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-remove-btn"
|
||||
onClick={() => handleRemoveDump(dump.id)}
|
||||
aria-label={t`Remove from playlist`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)
|
||||
: phase === "cooldown" && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
onClick={() => handleCancelRemove(dump.id)}
|
||||
aria-label={t`Cancel removal`}
|
||||
>
|
||||
<Trans>Undo</Trans>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this playlist? This cannot be undone.`}
|
||||
confirmLabel={t`Delete playlist`}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
void deletePlaylist();
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Passed to {@link PlaylistEditForm} by {@link PlaylistDetail}. */
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
onCancel: () => void;
|
||||
/** Opens the page's confirmation, which deletes the playlist. */
|
||||
onRequestDelete: () => void;
|
||||
/** Called with the playlist as the server now has it. */
|
||||
onSaved: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
image: File | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that edits the playlist, in place of the page's header. It
|
||||
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
|
||||
* title and description that changed, then a new cover, if one was picked,
|
||||
* with `POST /api/playlists/:playlistId/image`.
|
||||
*/
|
||||
function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
) {
|
||||
const { authFetch, mediaToken } = useAuth();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
// Each preview holds the file in memory until its URL is revoked: when the
|
||||
// next one replaces it, or when the form closes.
|
||||
useEffect(() => {
|
||||
if (!imagePreview) return;
|
||||
return () => URL.revokeObjectURL(imagePreview);
|
||||
}, [imagePreview]);
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: playlist.title,
|
||||
description: playlist.description ?? "",
|
||||
isPublic: playlist.isPublic,
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
const description = form.watch("description");
|
||||
const title = form.watch("title");
|
||||
|
||||
const onSubmit = form.submit(
|
||||
async ({ title, description, isPublic, image }) => {
|
||||
const updated = deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
...(title !== playlist.title ? { title } : {}),
|
||||
...(description !== (playlist.description ?? "")
|
||||
? { description: description || null }
|
||||
: {}),
|
||||
isPublic,
|
||||
} satisfies UpdatePlaylistRequest,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (image) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", image);
|
||||
// The upload answers with the playlist as it now stands (new cover,
|
||||
// new updatedAt), which is what the page should show.
|
||||
onSaved(deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(updated);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{
|
||||
/* `display: contents` keeps the image-beside-content flex layout while
|
||||
still giving us a real <form> for submit + Enter handling. */
|
||||
}
|
||||
<form style={{ display: "contents" }} onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? playlistImageUrl(playlist, mediaToken)
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
className="animate-in"
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="playlist-detail-content playlist-edit-content animate-in">
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={t`Playlist title`}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
onCancel={onCancel}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={2}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!title.trim() ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -28,30 +28,32 @@ import type { Dump } from "../../shared/dump.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
|
||||
import { friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
import { SEARCH_URL } from "../config/api.ts";
|
||||
import { expectOk } from "../utils/shared/api.ts";
|
||||
import {
|
||||
canLoadMore,
|
||||
firstPage,
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
} from "../utils/feed/pagedList.ts";
|
||||
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||
|
||||
const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
||||
type Tab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
// Only the dumps are paged: the API answers at most 20 users and playlists.
|
||||
type SearchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
q: string;
|
||||
dumps: {
|
||||
items: Dump[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
users: PublicUser[];
|
||||
playlists: Playlist[];
|
||||
};
|
||||
/**
|
||||
* Held by `Search` for a query, from the first page of `SearchResults`. The
|
||||
* dumps take the next pages.
|
||||
*/
|
||||
interface Results {
|
||||
dumps: PagedList<Dump>;
|
||||
/** It counts every match, which the pages loaded so far may not reach. */
|
||||
dumpTotal: number;
|
||||
users: PublicUser[];
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come
|
||||
@@ -65,131 +67,49 @@ export function Search(): React.JSX.Element {
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps");
|
||||
|
||||
const { token } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const search = useCallback(async (page: number, signal?: AbortSignal) => {
|
||||
const url = new URL(SEARCH_URL, location.origin);
|
||||
url.searchParams.set("q", q);
|
||||
return expectOk<Wire<SearchResults>>(
|
||||
await authFetch(pageUrl(url.toString(), page), { signal }),
|
||||
);
|
||||
}, [authFetch, q]);
|
||||
|
||||
const [state, setState] = useState<SearchState>({ status: "idle" });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSearch = useCallback(async (query: string, page: number) => {
|
||||
// Deferred: the set-state-in-effect lint rule forbids setting state
|
||||
// synchronously in the effect that calls this.
|
||||
if (!query.trim()) {
|
||||
queueMicrotask(() => setState({ status: "idle" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === 1) {
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const url = new URL(SEARCH_URL, location.origin);
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("page", String(page));
|
||||
url.searchParams.set("limit", String(DEFAULT_PAGE_SIZE));
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const body = await res.json() as {
|
||||
success: true;
|
||||
data: {
|
||||
dumps: { items: Wire<Dump>[]; total: number; hasMore: boolean };
|
||||
users: Wire<PublicUser>[];
|
||||
playlists: Wire<Playlist>[];
|
||||
};
|
||||
};
|
||||
|
||||
const { data } = body;
|
||||
|
||||
if (page === 1) {
|
||||
setState({
|
||||
status: "loaded",
|
||||
q: query,
|
||||
dumps: {
|
||||
items: data.dumps.items.map(deserializeDump),
|
||||
total: data.dumps.total,
|
||||
hasMore: data.dumps.hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
},
|
||||
users: data.users.map(deserializePublicUser),
|
||||
playlists: data.playlists.map(deserializePlaylist),
|
||||
});
|
||||
} else {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
dumps: {
|
||||
...prev.dumps,
|
||||
items: [
|
||||
...prev.dumps.items,
|
||||
...data.dumps.items.map(deserializeDump),
|
||||
],
|
||||
hasMore: data.dumps.hasMore,
|
||||
page,
|
||||
loadingMore: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
// A later page that fails leaves the results already shown; scrolling
|
||||
// back to the end tries it again.
|
||||
if (page > 1) {
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { ...prev, dumps: { ...prev.dumps, loadingMore: false } }
|
||||
: prev
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
// The lint rule flags the call itself, whatever fetchSearch defers inside.
|
||||
queueMicrotask(() => fetchSearch(q, 1));
|
||||
return () => abortRef.current?.abort();
|
||||
}, [q, fetchSearch]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.dumps.hasMore ||
|
||||
state.dumps.loadingMore
|
||||
) return;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
|
||||
});
|
||||
fetchSearch(q, state.dumps.page + 1);
|
||||
}, [state, q, fetchSearch]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore &&
|
||||
!state.dumps.loadingMore,
|
||||
const { state, setData } = useFetchState<Results>(
|
||||
q.trim() ? `${q}:${user?.id ?? "guest"}` : undefined,
|
||||
(signal) =>
|
||||
search(1, signal).then(({ dumps, users, playlists }) => ({
|
||||
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
|
||||
dumpTotal: dumps.total ?? 0,
|
||||
users: users.map(deserializePublicUser),
|
||||
playlists: playlists.map(deserializePlaylist),
|
||||
})),
|
||||
);
|
||||
|
||||
const dumpCount = state.status === "loaded" ? state.dumps.total : null;
|
||||
const userCount = state.status === "loaded" ? state.users.length : null;
|
||||
const playlistCount = state.status === "loaded"
|
||||
? state.playlists.length
|
||||
: null;
|
||||
const dumps = state.status === "loaded" ? state.data.dumps : null;
|
||||
const loadMore = useLoadMore(
|
||||
dumps,
|
||||
useCallback(
|
||||
(fn: (dumps: PagedList<Dump>) => PagedList<Dump>) =>
|
||||
setData((d) => ({ ...d, dumps: fn(d.dumps) })),
|
||||
[setData],
|
||||
),
|
||||
(page) =>
|
||||
search(page).then(({ dumps }) => ({
|
||||
items: dumps.items.map(deserializeDump),
|
||||
hasMore: dumps.hasMore,
|
||||
})),
|
||||
);
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
tab === "dumps" && canLoadMore(dumps),
|
||||
);
|
||||
|
||||
const results = state.status === "loaded" ? state.data : null;
|
||||
const dumpCount = results ? results.dumpTotal : null;
|
||||
const userCount = results ? results.users.length : null;
|
||||
const playlistCount = results ? results.playlists.length : null;
|
||||
|
||||
function tabLabel(tab: Tab, count: number | null) {
|
||||
const label = tab === "dumps"
|
||||
@@ -224,13 +144,13 @@ export function Search(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.status === "idle" && (
|
||||
{!q.trim() && (
|
||||
<p className="search-page-empty">
|
||||
<Trans>Enter a query to search.</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "loading" && (
|
||||
{q.trim() && state.status === "loading" && (
|
||||
<p className="search-page-empty">
|
||||
<Trans>Searching…</Trans>
|
||||
</p>
|
||||
@@ -240,29 +160,27 @@ export function Search(): React.JSX.Element {
|
||||
<ErrorCard title={t`Search failed`} message={state.error} />
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && tab === "dumps" && (
|
||||
<>
|
||||
{state.dumps.items.length === 0
|
||||
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
||||
: (
|
||||
<>
|
||||
<DumpCardList dumps={state.dumps.items} />
|
||||
<FeedFooter
|
||||
hasMore={state.dumps.hasMore}
|
||||
loadingMore={state.dumps.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{results && tab === "dumps" && (
|
||||
results.dumps.items.length === 0
|
||||
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
||||
: (
|
||||
<>
|
||||
<DumpCardList dumps={results.dumps.items} />
|
||||
<FeedFooter
|
||||
hasMore={results.dumps.hasMore}
|
||||
loadingMore={results.dumps.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && tab === "users" && (
|
||||
state.users.length === 0
|
||||
{results && tab === "users" && (
|
||||
results.users.length === 0
|
||||
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
|
||||
: (
|
||||
<ul className="user-results">
|
||||
{state.users.map((u) => (
|
||||
{results.users.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
to={`/users/${u.username}`}
|
||||
@@ -281,14 +199,14 @@ export function Search(): React.JSX.Element {
|
||||
)
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && tab === "playlists" && (
|
||||
state.playlists.length === 0
|
||||
{results && tab === "playlists" && (
|
||||
results.playlists.length === 0
|
||||
? (
|
||||
<p className="search-page-empty">
|
||||
{t`No playlists match "${q}".`}
|
||||
</p>
|
||||
)
|
||||
: <PlaylistCardList playlists={state.playlists} />
|
||||
: <PlaylistCardList playlists={results.playlists} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function UserDumps(): React.JSX.Element {
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const dumpItems = state.status === "loaded" ? state.items : [];
|
||||
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
|
||||
usePositionAwareSync(
|
||||
dumpItems,
|
||||
setItems,
|
||||
@@ -78,7 +78,7 @@ export function UserDumps(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, items: dumps, hasMore, loadingMore } = state;
|
||||
const { profileUser, dumps: { items: dumps, hasMore, loadingMore } } = state;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -10,9 +10,8 @@ import { Link, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
import {
|
||||
@@ -31,11 +30,16 @@ import {
|
||||
useScrollSave,
|
||||
} from "../hooks/feed/useScrollSave.ts";
|
||||
import {
|
||||
fetchFirstPage,
|
||||
canLoadMore,
|
||||
mapItems,
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
readPage,
|
||||
restoredList,
|
||||
withFirst,
|
||||
} from "../utils/feed/pagedList.ts";
|
||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||
import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts";
|
||||
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||
@@ -67,7 +71,7 @@ type State =
|
||||
*/
|
||||
export function UserPlaylists(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, token } = useAuth();
|
||||
const { user: me, authFetch } = useAuth();
|
||||
|
||||
useDocumentTitle(username ?? null);
|
||||
const { lastPlaylistEvent } = useWS();
|
||||
@@ -96,13 +100,31 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
||||
);
|
||||
}, []);
|
||||
type SetList = (
|
||||
fn: (list: PagedList<Playlist>) => PagedList<Playlist>,
|
||||
) => void;
|
||||
const setCreatedList = useCallback<SetList>(
|
||||
(fn) =>
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, created: fn(s.created) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
const setFollowedList = useCallback<SetList>(
|
||||
(fn) =>
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, followed: fn(s.followed) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
const setCreated = useCallback(
|
||||
(fn: (prev: Playlist[]) => Playlist[]) => setCreatedList(mapItems(fn)),
|
||||
[setCreatedList],
|
||||
);
|
||||
const setFollowed = useCallback(
|
||||
(fn: (prev: Playlist[]) => Playlist[]) => setFollowedList(mapItems(fn)),
|
||||
[setFollowedList],
|
||||
);
|
||||
const createdItems = state.status === "loaded" ? state.created.items : [];
|
||||
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||
? (lastPlaylistEvent.playlist ?? null)
|
||||
@@ -120,35 +142,29 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
ownerId: profileUserId ?? undefined,
|
||||
});
|
||||
|
||||
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
|
||||
);
|
||||
}, []);
|
||||
usePlaylistListSync(setFollowed, { noNewEntries: true });
|
||||
|
||||
const createdUrl = `${API_URL}/api/users/${username}/playlists`;
|
||||
const followedUrl = `${API_URL}/api/users/${username}/followed-playlists`;
|
||||
const fetchPage = (url: string) => (page: number) =>
|
||||
authFetch(pageUrl(url, page))
|
||||
.then((res) => readPage(res, deserializePlaylist));
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const firstPageOf = (url: string) =>
|
||||
authFetch(pageUrl(url, 1), { signal })
|
||||
.then((res) => readFirstPage(res, deserializePlaylist));
|
||||
|
||||
// Back on the page, the lists come from the cache and only the user is
|
||||
// fetched again.
|
||||
const cached = cachedCreated && cachedFollowed;
|
||||
Promise.all([
|
||||
fetchPublicUser(username, signal),
|
||||
cached ? restoredList(cachedCreated) : fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/playlists`,
|
||||
deserializePlaylist,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
|
||||
),
|
||||
cached ? restoredList(cachedFollowed) : fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/followed-playlists`,
|
||||
deserializePlaylist,
|
||||
{ signal },
|
||||
),
|
||||
cached ? restoredList(cachedCreated) : firstPageOf(createdUrl),
|
||||
cached ? restoredList(cachedFollowed) : firstPageOf(followedUrl),
|
||||
])
|
||||
.then(([profileUser, created, followed]) =>
|
||||
setState({ status: "loaded", profileUser, created, followed })
|
||||
@@ -158,114 +174,32 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [username, cachedCreated, cachedFollowed, token]);
|
||||
|
||||
const loadMoreCreated = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.created.hasMore ||
|
||||
state.created.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.created.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
created: {
|
||||
items: [...s.created.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMoreFollowed = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.followed.hasMore ||
|
||||
state.followed.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.followed.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
followed: {
|
||||
items: [...s.followed.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username]);
|
||||
}, [
|
||||
username,
|
||||
cachedCreated,
|
||||
cachedFollowed,
|
||||
authFetch,
|
||||
createdUrl,
|
||||
followedUrl,
|
||||
]);
|
||||
|
||||
const createdList = state.status === "loaded" ? state.created : null;
|
||||
const followedList = state.status === "loaded" ? state.followed : null;
|
||||
const createdSentinelRef = useInfiniteScroll(
|
||||
loadMoreCreated,
|
||||
state.status === "loaded" && state.created.hasMore &&
|
||||
!state.created.loadingMore,
|
||||
useLoadMore(createdList, setCreatedList, fetchPage(createdUrl)),
|
||||
canLoadMore(createdList),
|
||||
);
|
||||
const followedSentinelRef = useInfiniteScroll(
|
||||
loadMoreFollowed,
|
||||
state.status === "loaded" && state.followed.hasMore &&
|
||||
!state.followed.loadingMore,
|
||||
useLoadMore(followedList, setFollowedList, fetchPage(followedUrl)),
|
||||
canLoadMore(followedList),
|
||||
);
|
||||
|
||||
useScrollSave(
|
||||
state.status === "loaded",
|
||||
useCallback((y) => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveCreated(
|
||||
state.created.items,
|
||||
state.created.page,
|
||||
state.created.hasMore,
|
||||
y,
|
||||
);
|
||||
saveFollowed(
|
||||
state.followed.items,
|
||||
state.followed.page,
|
||||
state.followed.hasMore,
|
||||
y,
|
||||
);
|
||||
saveCreated(state.created, y);
|
||||
saveFollowed(state.followed, y);
|
||||
}, [state, saveCreated, saveFollowed]),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,20 +5,27 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Invitee, PublicUser } from "../../shared/user.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import {
|
||||
deserializeInvitee,
|
||||
deserializePlaylist,
|
||||
deserializePublicUser,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { dataOr, expectOk } from "../utils/shared/api.ts";
|
||||
import { dataOr } from "../utils/shared/api.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { fetchFirstPage } from "../utils/feed/pagedList.ts";
|
||||
import {
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
readPage,
|
||||
} from "../utils/feed/pagedList.ts";
|
||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
||||
@@ -92,22 +99,19 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
|
||||
|
||||
const loaded = state.status === "loaded";
|
||||
const followedUsersUrl = `${API_URL}/api/users/${username}/followed-users`;
|
||||
const followed = useFetchState<ProfileFollowed>(
|
||||
username,
|
||||
(signal) =>
|
||||
Promise.all([
|
||||
fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/followed-users`,
|
||||
deserializePublicUser,
|
||||
fetch(pageUrl(followedUsersUrl, 1), { signal })
|
||||
.then((res) => readFirstPage(res, deserializePublicUser)),
|
||||
fetch(
|
||||
pageUrl(`${API_URL}/api/users/${username}/followed-playlists`, 1),
|
||||
{ signal },
|
||||
),
|
||||
fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/followed-playlists`,
|
||||
deserializePlaylist,
|
||||
{ signal },
|
||||
),
|
||||
).then((res) => readFirstPage(res, deserializePlaylist)),
|
||||
]).then(([users, playlists]) => ({ users, playlists })),
|
||||
loaded && tab === "followed",
|
||||
{ enabled: loaded && tab === "followed" },
|
||||
);
|
||||
const invitees = useFetchState<Invitee[]>(
|
||||
username,
|
||||
@@ -115,33 +119,21 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
fetch(`${API_URL}/api/users/${username}/invitees`, { signal })
|
||||
.then((res) => dataOr<Wire<Invitee>[]>(res, []))
|
||||
.then((entries) => entries.map(deserializeInvitee)),
|
||||
loaded && tab === "invitees",
|
||||
{ enabled: loaded && tab === "invitees" },
|
||||
);
|
||||
|
||||
const loadMoreFollowedUsers = () => {
|
||||
if (
|
||||
followed.state.status !== "loaded" ||
|
||||
followed.state.data.users.loadingMore
|
||||
) return;
|
||||
const nextPage = followed.state.data.users.page + 1;
|
||||
const setUsers = (
|
||||
fn: (u: ProfileFollowed["users"]) => ProfileFollowed["users"],
|
||||
) => followed.setData((d) => ({ ...d, users: fn(d.users) }));
|
||||
setUsers((u) => ({ ...u, loadingMore: true }));
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<PublicUser>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setUsers((u) => ({
|
||||
items: [...u.items, ...items.map(deserializePublicUser)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}))
|
||||
)
|
||||
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
|
||||
};
|
||||
const setFollowed = followed.setData;
|
||||
const loadMoreFollowedUsers = useLoadMore(
|
||||
followed.state.status === "loaded" ? followed.state.data.users : null,
|
||||
useCallback(
|
||||
(fn: (users: PagedList<PublicUser>) => PagedList<PublicUser>) =>
|
||||
setFollowed((d) => ({ ...d, users: fn(d.users) })),
|
||||
[setFollowed],
|
||||
),
|
||||
(page) =>
|
||||
fetch(pageUrl(followedUsersUrl, page))
|
||||
.then((res) => readPage(res, deserializePublicUser)),
|
||||
);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, items: votes, hasMore, loadingMore } = state;
|
||||
const { profileUser, dumps: { items: votes, hasMore, loadingMore } } = state;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
Reference in New Issue
Block a user