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.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 21:19:33 +00:00
parent 69d9bf899b
commit 07ac4bc629
81 changed files with 2016 additions and 2210 deletions

View File

@@ -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)}
/>
))}

View File

@@ -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

View File

@@ -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>;
}

View File

@@ -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}

View File

@@ -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.

View File

@@ -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">,

View File

@@ -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. */

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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
*/

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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}
/>
)}
</>