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:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Renders a list of dumps as cards, wired to the live votes.
|
||||
* Renders dumps as cards wired to the live votes, one at a time or as a list.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,39 +10,57 @@ import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import { DumpCard } from "./DumpCard.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link DumpCardList} by every list of dump cards: the index's
|
||||
* "hot", "new" and "followed" feeds, the search results, a dump's related
|
||||
* dumps, and a profile's dumps and upvotes.
|
||||
* Passed to {@link LiveDumpCard} by {@link DumpCardList}, and by
|
||||
* `PlaylistDumpList`, which wraps each card in its drag handle and buttons.
|
||||
*/
|
||||
export interface DumpCardListProps {
|
||||
dumps: Dump[];
|
||||
/** A class for one card, as `fadeClassName` gives a fading one. */
|
||||
cardClassName?: (dump: Dump) => string | undefined;
|
||||
export interface LiveDumpCardProps {
|
||||
dump: Dump;
|
||||
/** It comes from `fadeClassName` for a fading card. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `dumps` as a `.dump-feed` of {@link DumpCard}. The vote counts and
|
||||
* calls come from `WSContext`, a dump missing from its counts showing its
|
||||
* fetched `voteCount`. Voting takes a signed-in user, and a card is its
|
||||
* owner's when it is the signed-in user's own dump.
|
||||
* Renders `dump` as a {@link DumpCard}, an `<li>`. The vote counts and calls
|
||||
* come from `WSContext`, a dump missing from its counts showing its fetched
|
||||
* `voteCount`. Voting takes a signed-in user, and a card is its owner's when
|
||||
* it is the signed-in user's own dump.
|
||||
*/
|
||||
export function DumpCardList(
|
||||
{ dumps, cardClassName }: DumpCardListProps,
|
||||
export function LiveDumpCard(
|
||||
{ dump, className }: LiveDumpCardProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
return (
|
||||
<DumpCard
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Passed to {@link DumpCardList} by every plain list of dump cards. */
|
||||
export interface DumpCardListProps {
|
||||
dumps: Dump[];
|
||||
/** Gives a card a class, as `fadeClassName` gives a fading one. */
|
||||
cardClassName?: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
/** Renders `dumps` as a `.dump-feed` of {@link LiveDumpCard}. */
|
||||
export function DumpCardList(
|
||||
{ dumps, cardClassName }: DumpCardListProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
<DumpCard
|
||||
<LiveDumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
className={cardClassName?.(dump)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "hot" and "new" tabs of the index page. `Index` fetches the feed and
|
||||
* passes it down as {@link MainFeedProps}.
|
||||
* Renders the "hot" and "new" tabs of the index page, from the feed `Index`
|
||||
* loads and passes down as {@link MainFeedProps}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,30 +11,32 @@ import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { FeedFooter } from "./FeedFooter.tsx";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { LoadState } from "../../utils/shared/loadState.ts";
|
||||
import type { PagedList } from "../../utils/feed/pagedList.ts";
|
||||
import type { FetchState } from "../../hooks/shared/useFetchState.ts";
|
||||
import { hotScore } from "../../utils/feed/hotScore.ts";
|
||||
|
||||
/**
|
||||
* Held by `Index` for its feed, and by `FollowedFeed` for each of its
|
||||
* sections: the pages of dumps loaded so far.
|
||||
* Held by `usePagedList` for `Index`'s feed and for each section of
|
||||
* `FollowedFeed`.
|
||||
*/
|
||||
export type DumpFeedState = LoadState<{
|
||||
dumps: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
}>;
|
||||
export type DumpFeedState = FetchState<PagedList<Dump>>;
|
||||
|
||||
/**
|
||||
* Passed by `Index` to whichever of the "hot", "new" and "journal" tabs is
|
||||
* showing. The tabs share this one state, so switching tabs doesn't refetch.
|
||||
*/
|
||||
export interface MainFeedProps {
|
||||
/** The feed's status and paging. Its own dumps are in `dumps`. */
|
||||
/**
|
||||
* The tabs read its status and paging, and show `dumps` instead of its
|
||||
* items.
|
||||
*/
|
||||
state: DumpFeedState;
|
||||
/** The loaded dumps, with those `WSContext` added since and less deleted. */
|
||||
/**
|
||||
* It holds the loaded dumps, with those `WSContext` added since and without
|
||||
* the deleted ones.
|
||||
*/
|
||||
dumps: Dump[];
|
||||
/** From `useInfiniteScroll`. The feed places it after the last card. */
|
||||
/** It comes from `useInfiniteScroll`, and goes after the last card. */
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ export function DumpFeed(
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ hasMore, loadingMore }) => (
|
||||
{({ data: { hasMore, loadingMore } }) => (
|
||||
<>
|
||||
<DumpCardList dumps={sorted} />
|
||||
<FeedFooter
|
||||
|
||||
@@ -9,12 +9,12 @@ import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/**
|
||||
* Passed to {@link FeedFooter} by every list that loads its next page on
|
||||
* scroll: the index's feeds, the search results and a profile's full lists.
|
||||
* scroll.
|
||||
*/
|
||||
export interface FeedFooterProps {
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
/** From `useInfiniteScroll`. */
|
||||
/** It comes from `useInfiniteScroll`. */
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* The "followed" tab of the index page, which shows the dumps of the users and
|
||||
* the playlists the signed-in user follows.
|
||||
* Renders the "followed" tab of the index page, which shows the dumps of the
|
||||
* users and the playlists the signed-in user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -13,19 +13,19 @@ import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { FeedFooter } from "./FeedFooter.tsx";
|
||||
import type { DumpFeedState } from "./DumpFeed.tsx";
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
|
||||
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||
import { useScrollSave } from "../../hooks/feed/useScrollSave.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";
|
||||
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
@@ -38,97 +38,33 @@ function useFollowedSection(
|
||||
section: FollowedSection,
|
||||
categorySlug: string | undefined,
|
||||
): { state: DumpFeedState; loadMore: () => void } {
|
||||
const { token } = useAuth();
|
||||
const catQuery = categorySlug
|
||||
? `&category=${encodeURIComponent(categorySlug)}`
|
||||
: "";
|
||||
const { user, authFetch } = useAuth();
|
||||
const url = `${API_URL}/api/follows/feed/${section}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:followed-${section}:${categorySlug ?? "all"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [state, setState] = useState<DumpFeedState>(() =>
|
||||
cached
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }),
|
||||
[],
|
||||
const { state, setItems, loadMore } = usePagedList(
|
||||
user ? `${url}:${user.id}` : undefined,
|
||||
(page, signal) =>
|
||||
authFetch(pageUrl(url, page), { signal })
|
||||
.then((res) => readPage(res, deserializeDump)),
|
||||
cached,
|
||||
);
|
||||
useDumpListSync(setItems);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || state.status !== "loading") return;
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/${section}?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<Dump>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
setState({ status: "error", error: friendlyFetchError(err) })
|
||||
);
|
||||
}, [token, state.status, section, catQuery]);
|
||||
|
||||
// Only the list is kept here: `Index` saves and restores the page's scroll
|
||||
// for every tab, this one included.
|
||||
// Only the list is kept here, since `Index` saves and restores the page's
|
||||
// scroll for every tab, this one included.
|
||||
useScrollSave(
|
||||
state.status === "loaded",
|
||||
useCallback(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveState(state.dumps, state.page, state.hasMore, 0);
|
||||
if (state.status === "loaded") saveState(state.data, 0);
|
||||
}, [state, saveState]),
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||
!token
|
||||
) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/${section}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<Dump>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [state, token, section, catQuery]);
|
||||
|
||||
return { state, loadMore };
|
||||
}
|
||||
|
||||
@@ -144,18 +80,18 @@ function FollowedSubFeed(
|
||||
) {
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
onLoadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
state.status === "loaded" && canLoadMore(state.data),
|
||||
);
|
||||
|
||||
return (
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ dumps }) => dumps.length === 0}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={<p className="index-status">{emptyMessage}</p>}
|
||||
>
|
||||
{({ dumps, hasMore, loadingMore }) => (
|
||||
{({ data: { items, hasMore, loadingMore } }) => (
|
||||
<>
|
||||
<DumpCardList dumps={dumps} />
|
||||
<DumpCardList dumps={items} />
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "journal" tab of the index page, which lays the feed out as a mosaic of
|
||||
* tiles of several sizes.
|
||||
* Renders the "journal" tab of the index page, which lays the feed out as a
|
||||
* mosaic of tiles of several sizes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -37,7 +37,7 @@ export function JournalFeed(
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ hasMore, loadingMore }) => (
|
||||
{({ data: { hasMore, loadingMore } }) => (
|
||||
<>
|
||||
{
|
||||
/* Each block is its own grid, with a themable <hr> between them.
|
||||
|
||||
@@ -59,7 +59,7 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
);
|
||||
}
|
||||
|
||||
/** The Public/Private options, true for public. */
|
||||
// Public is true.
|
||||
const VISIBILITY_OPTIONS: SegmentedOption<boolean>[] = [
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
{ value: false, label: <Trans>Private</Trans> },
|
||||
@@ -95,8 +95,8 @@ export function VisibilityToggle<T extends FieldValues>({
|
||||
|
||||
/**
|
||||
* Renders a Public/Private {@link Segmented} outside `SegmentedField`, for
|
||||
* `PlaylistDetail`'s edit form, which binds it with its own `Controller` and
|
||||
* styles it apart.
|
||||
* `PlaylistEditForm`, which binds it with its own `Controller` and styles it
|
||||
* apart.
|
||||
*/
|
||||
export function VisibilitySegmented(
|
||||
props: Omit<SegmentedProps<boolean>, "options">,
|
||||
|
||||
@@ -8,11 +8,7 @@ import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { PlaylistCard } from "./PlaylistCard.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link PlaylistCardList} by every list of playlist cards: the
|
||||
* profile's and `UserPlaylists`' lists, the search results and a dump's
|
||||
* collections.
|
||||
*/
|
||||
/** Passed to {@link PlaylistCardList} by every list of playlist cards. */
|
||||
export interface PlaylistCardListProps {
|
||||
playlists: Playlist[];
|
||||
/** Given, each card shows a delete button, which calls it. */
|
||||
|
||||
128
src/components/playlist/PlaylistDumpList.tsx
Normal file
128
src/components/playlist/PlaylistDumpList.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Renders the dumps of a playlist's page, which those who may edit it drag
|
||||
* into a new order and remove.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { fadeClassName, type FadeState } from "../../hooks/feed/useFading.ts";
|
||||
import { LiveDumpCard } from "../dump/DumpCardList.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link PlaylistDumpList} by `PlaylistDetail`, from `usePlaylist`.
|
||||
* The calls are given only to those who may edit the playlist.
|
||||
*/
|
||||
export interface PlaylistDumpListProps {
|
||||
dumps: Dump[];
|
||||
fading: Record<string, FadeState>;
|
||||
onRemove?: (dumpId: string) => void;
|
||||
onUndoRemove?: (dumpId: string) => void;
|
||||
/** Counts only the dumps not fading, which alone can be dragged. */
|
||||
onMove?: (from: number, to: number) => void;
|
||||
/** Called as a drag ends, to save the order `onMove` left. */
|
||||
onMoved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dumps, or a line saying there are none. With the calls, each
|
||||
* dump not fading has a drag handle and a remove button, and one fading an
|
||||
* "Undo" during its cooldown.
|
||||
*/
|
||||
export function PlaylistDumpList(
|
||||
{ dumps, fading, onRemove, onUndoRemove, onMove, onMoved }:
|
||||
PlaylistDumpListProps,
|
||||
): React.JSX.Element {
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
if (dumps.length === 0) {
|
||||
return (
|
||||
<p className="empty-state">
|
||||
<Trans>No dumps in this playlist yet.</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
onMove?.(src, index);
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
const dragged = dragSrcRef.current !== null;
|
||||
dragSrcRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
if (dragged) onMoved?.();
|
||||
};
|
||||
|
||||
const canDrag = !!onMove;
|
||||
let activeIndex = 0;
|
||||
return (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={canDrag ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{dumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const index = phase ? -1 : activeIndex++;
|
||||
const draggable = canDrag && !phase;
|
||||
return (
|
||||
<div
|
||||
key={dump.id}
|
||||
className={`playlist-dump-item${
|
||||
draggable && index === dragOverIndex
|
||||
? " playlist-dump-item--drag-over"
|
||||
: ""
|
||||
}`}
|
||||
draggable={draggable}
|
||||
onDragStart={draggable
|
||||
? () => {
|
||||
dragSrcRef.current = index;
|
||||
}
|
||||
: undefined}
|
||||
onDragOver={draggable ? (e) => handleDragOver(e, index) : undefined}
|
||||
onDragEnd={canDrag ? handleDragEnd : undefined}
|
||||
>
|
||||
{draggable && <span className="drag-handle" aria-hidden>⠿</span>}
|
||||
<LiveDumpCard dump={dump} className={fadeClassName(phase)} />
|
||||
{!phase && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-remove-btn"
|
||||
onClick={() => onRemove(dump.id)}
|
||||
aria-label={t`Remove from playlist`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{phase === "cooldown" && onUndoRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
onClick={() => onUndoRemove(dump.id)}
|
||||
aria-label={t`Cancel removal`}
|
||||
>
|
||||
<Trans>Undo</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
src/components/playlist/PlaylistEditForm.tsx
Normal file
220
src/components/playlist/PlaylistEditForm.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Renders the form that edits a playlist's details and cover, in place of the
|
||||
* header of its page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
UpdatePlaylistRequest,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
|
||||
import { playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { CountedInput } from "../shared/CountedInput.tsx";
|
||||
import { ImagePicker } from "../shared/ImagePicker.tsx";
|
||||
import { TextEditor } from "../editor/TextEditor.tsx";
|
||||
import { VisibilitySegmented } from "../form/SegmentedField.tsx";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
|
||||
/** Passed to {@link PlaylistEditForm} by `PlaylistDetail`. */
|
||||
export 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`.
|
||||
*/
|
||||
export function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
): React.JSX.Element {
|
||||
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 header's layout, the cover beside the
|
||||
fields, around a real <form>, which Enter submits. */
|
||||
}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
101
src/components/playlist/PlaylistHeader.tsx
Normal file
101
src/components/playlist/PlaylistHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Renders the header of a playlist's page, with its cover, title, description,
|
||||
* visibility, owner and dates.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
|
||||
import { playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
||||
export interface PlaylistHeaderProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
/** Given to those who may edit it, for whom it adds an "Edit" button. */
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the playlist's details, with a follow button for anyone but its
|
||||
* owner. `PlaylistEditForm` takes its place while it is edited.
|
||||
*/
|
||||
export function PlaylistHeader(
|
||||
{ playlist, onEdit }: PlaylistHeaderProps,
|
||||
): React.JSX.Element {
|
||||
const { user, mediaToken } = useAuth();
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
{user?.id !== playlist.userId && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,8 @@ export function DescriptionEditor(
|
||||
|
||||
const onSubmit = form.submit(async ({ description }) => {
|
||||
const trimmed = description.trim();
|
||||
// Sent even when empty: left out, the server would keep the old one.
|
||||
// Sent even when empty, since the server keeps the old one when it is left
|
||||
// out.
|
||||
await expectOk<Wire<PublicUser>>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The "dumps" tab of a profile: the user's dumps beside those they upvoted.
|
||||
* Renders the "dumps" tab of a profile, which shows the user's dumps beside
|
||||
* those they upvoted.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,7 +19,7 @@ export interface ProfileDumpsTabProps {
|
||||
dumps: PagedList<Dump>;
|
||||
votes: PagedList<Dump>;
|
||||
isOwnProfile: boolean;
|
||||
/** From `useProfile`, which fades out a withdrawn upvote. */
|
||||
/** It comes from `useProfile`, which fades out a withdrawn upvote. */
|
||||
upvotedCardClassName: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "followed" tab of a profile: the users and the playlists the user
|
||||
* follows.
|
||||
* Renders the "followed" tab of a profile, which shows the users and the
|
||||
* playlists the user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "invitees" tab of a profile: whom the user invited, and whom they
|
||||
* invited in turn.
|
||||
* Renders the "invitees" tab of a profile, which shows whom the user invited,
|
||||
* and whom they invited in turn.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "manage" tab of a profile, which holds tools for another user on their
|
||||
* profile and site-wide tools on one's own.
|
||||
* Renders the "manage" tab of a profile, which holds tools for another user on
|
||||
* their profile and site-wide tools on one's own.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Renders the actions of the signed-in user's own profile header: inviting
|
||||
* someone, and whatever the header adds, the log out button.
|
||||
* Renders the actions of the signed-in user's own profile header, which invite
|
||||
* someone, beside the log out button the header adds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The "playlists" tab of a profile: the playlists the user made.
|
||||
* Renders the "playlists" tab of a profile, which shows the playlists the user
|
||||
* made.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ export interface ProfilePlaylistsTabProps {
|
||||
username: string;
|
||||
playlists: PagedList<Playlist>;
|
||||
isOwnProfile: boolean;
|
||||
/** From `useProfile`, to put a playlist made here first. */
|
||||
/** It comes from `useProfile`, and puts a playlist made here first. */
|
||||
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import { Trans } from "@lingui/react/macro";
|
||||
*/
|
||||
export interface ProfileSectionProps {
|
||||
title: ReactNode;
|
||||
/** Buttons at the end of the title row. */
|
||||
/** They go at the end of the title row. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* The page with the whole list, of which the section shows the first page.
|
||||
* The profile leaves it out while the list is empty.
|
||||
* It links to the page with the whole list, of which the section shows the
|
||||
* first page. The profile leaves it out while the list is empty.
|
||||
*/
|
||||
viewAllHref?: string;
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "settings" tab of the signed-in user's own profile: their password, and
|
||||
* this browser's preferences.
|
||||
* Renders the "settings" tab of the signed-in user's own profile, where they
|
||||
* change their password and this browser's preferences.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ReactNode } from "react";
|
||||
*/
|
||||
export interface SettingRowProps {
|
||||
label: ReactNode;
|
||||
/** The control, usually a `Segmented`. */
|
||||
/** It is the row's control, usually a `Segmented`. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/**
|
||||
* Passed to {@link LoadMoreButton} by `Notifications`, `UserListPopover` and
|
||||
* the profile's followed users, which render it while there is more to load.
|
||||
* Passed to {@link LoadMoreButton} by the lists that load their next page on a
|
||||
* click, which render it while there is more to load.
|
||||
*/
|
||||
export interface LoadMoreButtonProps {
|
||||
loadingMore: boolean;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface FollowUserButtonProps {
|
||||
targetUsername: string;
|
||||
}
|
||||
|
||||
/** Passed to {@link FollowPlaylistButton} by the `PlaylistDetail` page. */
|
||||
/** Passed to {@link FollowPlaylistButton} by `PlaylistHeader`. */
|
||||
export interface FollowPlaylistButtonProps {
|
||||
targetPlaylistId: string;
|
||||
/** Hides the button, since only a public playlist can be followed. */
|
||||
|
||||
@@ -8,16 +8,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { LoadMoreButton } from "../shared/LoadMoreButton.tsx";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||
import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
|
||||
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link UserListPopover} by the button whose count was clicked. The
|
||||
@@ -28,23 +25,12 @@ export interface UserListPopoverProps {
|
||||
/** Called on an outside click, on Escape, and on a scroll outside it. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* A paginated endpoint answering `PaginatedData<PublicUser>`, without a query
|
||||
* string: the popover appends `page` and `limit`.
|
||||
* It answers `PaginatedData<PublicUser>`, a page at a time, and the popover
|
||||
* adds `page` and `limit` to its query.
|
||||
*/
|
||||
fetchUrl: string;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
users: PublicUser[];
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
// In viewport coordinates. `bottom` replaces `top` when the popover opens above
|
||||
// the anchor, so that it grows upward.
|
||||
interface Placement {
|
||||
@@ -69,8 +55,13 @@ const MIN_USABLE_HEIGHT = 100;
|
||||
export function UserListPopover(
|
||||
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
|
||||
): React.ReactPortal | null {
|
||||
const { token } = useAuth();
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const { user, authFetch } = useAuth();
|
||||
const { state, loadMore } = usePagedList(
|
||||
`${fetchUrl}:${user?.id ?? "guest"}`,
|
||||
(page, signal) =>
|
||||
authFetch(pageUrl(fetchUrl, page), { signal })
|
||||
.then((res) => readPage(res, deserializePublicUser)),
|
||||
);
|
||||
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -139,71 +130,6 @@ export function UserListPopover(
|
||||
};
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(`${fetchUrl}?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const apiResponse = parseAPIResponse<PaginatedData<Wire<PublicUser>>>(
|
||||
body,
|
||||
);
|
||||
if (!apiResponse.success) {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
status: "loaded",
|
||||
users: apiResponse.data.items.map(deserializePublicUser),
|
||||
page: 1,
|
||||
hasMore: apiResponse.data.hasMore,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({ status: "error", error: t`Could not load.` });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [fetchUrl, token]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (state.status !== "loaded" || state.loadingMore) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState({ ...state, loadingMore: true });
|
||||
fetch(`${fetchUrl}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const apiResponse = parseAPIResponse<PaginatedData<Wire<PublicUser>>>(
|
||||
body,
|
||||
);
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
if (!apiResponse.success) return { ...prev, loadingMore: false };
|
||||
return {
|
||||
status: "loaded",
|
||||
users: [
|
||||
...prev.users,
|
||||
...apiResponse.data.items.map(deserializePublicUser),
|
||||
],
|
||||
page: nextPage,
|
||||
hasMore: apiResponse.data.hasMore,
|
||||
loadingMore: false,
|
||||
};
|
||||
});
|
||||
})
|
||||
// Offline or an unreadable reply: give the button back for a retry.
|
||||
.catch(() =>
|
||||
setState((prev) =>
|
||||
prev.status === "loaded" ? { ...prev, loadingMore: false } : prev
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (!placement) return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -227,14 +153,14 @@ export function UserListPopover(
|
||||
{state.status === "error" && (
|
||||
<p className="user-list-popover-status">{state.error}</p>
|
||||
)}
|
||||
{state.status === "loaded" && state.users.length === 0 && (
|
||||
{state.status === "loaded" && state.data.items.length === 0 && (
|
||||
<p className="user-list-popover-status">
|
||||
<Trans>No one yet.</Trans>
|
||||
</p>
|
||||
)}
|
||||
{state.status === "loaded" && state.users.length > 0 && (
|
||||
{state.status === "loaded" && state.data.items.length > 0 && (
|
||||
<>
|
||||
{state.users.map((u) => (
|
||||
{state.data.items.map((u) => (
|
||||
<Link
|
||||
key={u.id}
|
||||
to={`/users/${u.username}`}
|
||||
@@ -251,11 +177,11 @@ export function UserListPopover(
|
||||
<span>{u.username}</span>
|
||||
</Link>
|
||||
))}
|
||||
{state.hasMore && (
|
||||
{state.data.hasMore && (
|
||||
<LoadMoreButton
|
||||
className="user-list-popover-load-more"
|
||||
loadingMore={state.loadingMore}
|
||||
onClick={handleLoadMore}
|
||||
loadingMore={state.data.loadingMore}
|
||||
onClick={loadMore}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user