diff --git a/api/config.ts b/api/config.ts index 22b8d675..1a0aeca9 100644 --- a/api/config.ts +++ b/api/config.ts @@ -55,10 +55,10 @@ export const PUBLIC_URL: string = Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || `http://localhost:${PORT}`; /** - * The URL the frontend is served from (`GERBEUR_FRONTEND_URL`), when it isn't - * {@link PUBLIC_URL}, as with a separate Vite server, without a trailing slash. - * It is an allowed origin, and password reset links and the Open Graph tags' - * page URLs point to it. + * Holds the URL the frontend is served from (`GERBEUR_FRONTEND_URL`), without + * a trailing slash, when it isn't {@link PUBLIC_URL}, as with a separate Vite + * server. It is an allowed origin, and password reset links and the Open Graph + * tags' page URLs point to it. */ export const FRONTEND_URL: string = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") || @@ -95,7 +95,7 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; /** - * The largest image upload accepted, in bytes, for avatars, covers, thumbnails + * Caps the size of an image upload, in bytes, for avatars, covers, thumbnails * and attachments. lib/upload.ts states it in megabytes in its error. */ export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; diff --git a/api/db/connection.ts b/api/db/connection.ts index c14d81e3..1a770d03 100644 --- a/api/db/connection.ts +++ b/api/db/connection.ts @@ -15,8 +15,8 @@ import { runStartupTasks } from "./startup.ts"; const isNew = await Deno.stat(DB_PATH).then(() => false, () => true); /** - * The server's only connection. Its queries are synchronous, so each one - * blocks the event loop while it runs. + * Serves every query of the server, as its only connection. Its queries are + * synchronous, so each one blocks the event loop while it runs. */ export const db: DatabaseSync = new DatabaseSync(DB_PATH); // schema.sql holds every migration's change, so on a new database each diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 23d8043d..59aa8418 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -32,10 +32,10 @@ interface Migration { } /** - * Every migration, in the order they run. A new one goes at the end, along - * with the same change to schema.sql, and a shipped one is never edited or - * moved. A database connection.ts made from schema.sql records none of them, - * so each `up` must find its change already made and leave it be. + * Lists every migration, in the order they run. A new one goes at the end, + * along with the same change to schema.sql, and a shipped one is never edited + * or moved. A database that connection.ts made from schema.sql records none of + * them, so each `up` must find its change already made and leave it be. */ const MIGRATIONS: Migration[] = [ { name: "0001_comment_likes", up: up0001CommentLikes }, diff --git a/api/db/startup.ts b/api/db/startup.ts index a6347297..deb350a0 100644 --- a/api/db/startup.ts +++ b/api/db/startup.ts @@ -13,7 +13,9 @@ import { } from "../config.ts"; import { hashPassword } from "../lib/password.ts"; -/** Returns the time `ms` ago as an ISO string, the format dates are stored in. */ +/** + * Returns the time `ms` ago as an ISO string, the format dates are stored in. + */ function isoAgo(ms: number): string { return new Date(Date.now() - ms).toISOString(); } @@ -21,8 +23,9 @@ function isoAgo(ms: number): string { /** * Runs after the migrations, each time the server starts. It deletes used or * expired reset tokens, invites past their validity, and attachments nothing - * links to or whose resource is gone. On an instance with no users, it creates the user `admin` with the - * password `admin`, which whoever installs it must change. + * links to or whose resource is gone. On an instance with no users, it creates + * the user `admin` with the password `admin`, which whoever installs it must + * change. */ export async function runStartupTasks(db: DatabaseSync): Promise { // Dates are stored as ISO strings and compared as text, so the cutoffs are diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index 23a33d42..effeb18a 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -7,11 +7,11 @@ */ /** - * The top-level path segments a category slug may not take. They are the SPA's - * own routes (`src/App.tsx`); `api`, `ws`, `assets` and `emojibase`, which the - * server answers before the SPA; and `manage`, a former route whose old links - * may still be about. Keep it in step with the route table and the build's - * top-level folders. + * Lists the top-level path segments a category slug may not take. They are + * the SPA's own routes (`src/App.tsx`); `api`, `ws`, `assets` and + * `emojibase`, which the server answers before the SPA; and `manage`, a + * former route whose old links may still be about. Keep it in step with the + * route table and the build's top-level folders. */ export const RESERVED_SLUGS: readonly string[] = [ "~", diff --git a/api/lib/static.ts b/api/lib/static.ts index 22434200..40fd07c2 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -38,7 +38,7 @@ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; export function routeStaticFilesFrom( staticPaths: string[], ): (context: Context>, next: Next) => Promise { - // The last middleware: it always answers, so it never calls `next`. + // It is the last middleware and always answers, so it never calls `next`. return async (context: Context>, _next: Next) => { const pathname = context.request.url.pathname; diff --git a/api/remote/fetch.ts b/api/remote/fetch.ts index 094d62c6..a72f1884 100644 --- a/api/remote/fetch.ts +++ b/api/remote/fetch.ts @@ -183,7 +183,7 @@ export async function readTextCapped( try { return new TextDecoder(charset).decode(bytes); } catch { - // An unknown label: TextDecoder throws rather than guess. + // TextDecoder throws on a label it doesn't know rather than guess. return new TextDecoder().decode(bytes); } } diff --git a/api/remote/providers/bandcamp.ts b/api/remote/providers/bandcamp.ts index 05b57dc9..705018f0 100644 --- a/api/remote/providers/bandcamp.ts +++ b/api/remote/providers/bandcamp.ts @@ -12,9 +12,10 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts"; /** - * Claims bandcamp.com and its subdomains, where artists have their pages. Takes - * the embed player's URL from the page's `og:video`. The native player fetches the page's streams - * itself at play time, through `GET /api/bandcamp/tracks`. + * Claims bandcamp.com and its subdomains, where artists have their pages, and + * takes the embed player's URL from the page's `og:video`. The native player + * fetches the page's streams itself at play time, through + * `GET /api/bandcamp/tracks`. */ export const bandcampProvider: RichContentProvider = { name: "bandcamp", diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index 1ff52493..878f9e30 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -20,7 +20,10 @@ export function createAttachment(id: string, mime: string): void { ).run(id, mime, new Date().toISOString()); } -/** The kinds of resource whose saved text can link an attachment. */ +/** + * Stored in `attachments.resource_type` by {@link bindAttachments}, it names + * the kind of resource whose saved text links the attachment. + */ export type AttachmentResourceType = | "dump" | "comment" diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index 634aa34f..e3c40337 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -42,8 +42,9 @@ function resolveDumpPathId(idOrSlug: string): string | null { * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * of its comments (`comment`), with the links its current `text` holds. A link * counts when it is a dump's page on this site, or when it is the URL a dump - * was posted with, compared by `canonicalizeUrl` as reposts are. The dump and comment services call it on every save, and with - * an empty `text` when a comment is deleted. + * was posted with, compared by `canonicalizeUrl` as reposts are. The dump and + * comment services call it on every save, and with an empty `text` when a + * comment is deleted. */ export function relinkBacklinks( sourceType: "dump_comment" | "comment", diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index b33cdd81..06931a08 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -160,8 +160,8 @@ export function broadcastPresence(): void { * Tells the clients that may see a dump how it changed, for the dump service * after every write. `before` is null for a new dump, `after` for a deleted * one. A private dump's changes reach its owner's sockets alone. When a dump - * goes private, everyone else gets `dump_deleted`; when it goes public, they get - * `dump_created`, since to them it is new. + * goes private, everyone else gets `dump_deleted`; when it goes public, they + * get `dump_created`, since to them it is new. */ export function broadcastDumpChange( before: Dump | null, diff --git a/shared/api.ts b/shared/api.ts index 140b2b3b..1c44ddac 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -7,9 +7,9 @@ */ /** - * The codes an {@link APIError} may carry, one per HTTP status, which the - * backend's `APIException` derives from the status it is given. The frontend - * shows the message and doesn't read the code. + * Lists the codes an {@link APIError} carries, one per HTTP status. The + * backend's `APIException` derives its code from the status it is given, and + * the frontend shows the message without reading the code. */ export const APIErrorCode = { BAD_REQUEST: "BAD_REQUEST", diff --git a/shared/validation.ts b/shared/validation.ts index c185e919..2f61062d 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -29,8 +29,8 @@ export const VALIDATION = { } as const; /** - * Matches a valid username: letters, digits and underscores, within the - * `VALIDATION` limits. Sign-up checks it on both sides; it is the same `\w` + * Matches a valid username, made of letters, digits and underscores within the + * `VALIDATION` limits. Sign-up checks it on both sides. It uses the same `\w` * that a mention's pattern reads a name with. */ export const USERNAME_RE: RegExp = new RegExp( diff --git a/shared/ws.ts b/shared/ws.ts index 9ae38985..d55122e3 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -249,10 +249,10 @@ export interface CommentLikeAckMessage { } /** - * Sent by the server when a dump is posted: to every socket if it is public, - * to its poster's alone if private. Also sent to everyone but the poster when - * a private dump goes public, since to them it is new. `WSProvider` puts it at - * the top of its recent dumps. + * Sent by the server when a dump is posted, to every socket if it is public + * and to its poster's alone if it is private. It also goes to everyone but the + * poster when a private dump goes public, since to them it is new. + * `WSProvider` puts it at the top of its recent dumps. */ export interface DumpCreatedMessage { type: "dump_created"; @@ -260,8 +260,8 @@ export interface DumpCreatedMessage { } /** - * Sent by the server when a dump changes: to every socket if it is public, to - * its poster's alone if private, as when it has just gone private. + * Sent by the server when a dump changes, to every socket if it is public and + * to its poster's alone if it is private, as when it has just gone private. * `WSProvider` refreshes it in place, without moving it up the feeds. */ export interface DumpUpdatedMessage { @@ -270,9 +270,9 @@ export interface DumpUpdatedMessage { } /** - * Sent by the server when a dump is deleted: to every socket if it was - * public, to its poster's alone if private. Also sent to everyone but the - * poster when a public dump goes private. `WSProvider` drops it from every + * Sent by the server when a dump is deleted, to every socket if it was public + * and to its poster's alone if it was private. It also goes to everyone but + * the poster when a public dump goes private. `WSProvider` drops it from every * list. */ export interface DumpDeletedMessage { diff --git a/src/components/dump/DumpCardList.tsx b/src/components/dump/DumpCardList.tsx index ea275c5e..8567763c 100644 --- a/src/components/dump/DumpCardList.tsx +++ b/src/components/dump/DumpCardList.tsx @@ -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 `
  • `. 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 ( + + ); +} + +/** 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 (
      {dumps.map((dump) => ( - ))} diff --git a/src/components/feed/DumpFeed.tsx b/src/components/feed/DumpFeed.tsx index a2bc2100..fae07103 100644 --- a/src/components/feed/DumpFeed.tsx +++ b/src/components/feed/DumpFeed.tsx @@ -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>; /** * 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; } @@ -75,7 +77,7 @@ export function DumpFeed(

      } > - {({ hasMore, loadingMore }) => ( + {({ data: { hasMore, loadingMore } }) => ( <> ; } diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index 7cd597df..1ffd2726 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -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( `feed:followed-${section}:${categorySlug ?? "all"}`, hydrateDump, ); - const [state, setState] = useState(() => - 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>>) - .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>>) - .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 ( dumps.length === 0} + isEmpty={({ data }) => data.items.length === 0} empty={

      {emptyMessage}

      } > - {({ dumps, hasMore, loadingMore }) => ( + {({ data: { items, hasMore, loadingMore } }) => ( <> - + } > - {({ hasMore, loadingMore }) => ( + {({ data: { hasMore, loadingMore } }) => ( <> { /* Each block is its own grid, with a themable
      between them. diff --git a/src/components/form/SegmentedField.tsx b/src/components/form/SegmentedField.tsx index 4296a8e7..f72b8c07 100644 --- a/src/components/form/SegmentedField.tsx +++ b/src/components/form/SegmentedField.tsx @@ -59,7 +59,7 @@ export function SegmentedField({ ); } -/** The Public/Private options, true for public. */ +// Public is true. const VISIBILITY_OPTIONS: SegmentedOption[] = [ { value: true, label: Public }, { value: false, label: Private }, @@ -95,8 +95,8 @@ export function VisibilityToggle({ /** * 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, "options">, diff --git a/src/components/playlist/PlaylistCardList.tsx b/src/components/playlist/PlaylistCardList.tsx index 4b0adb94..d5ce00ba 100644 --- a/src/components/playlist/PlaylistCardList.tsx +++ b/src/components/playlist/PlaylistCardList.tsx @@ -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. */ diff --git a/src/components/playlist/PlaylistDumpList.tsx b/src/components/playlist/PlaylistDumpList.tsx new file mode 100644 index 00000000..fb9810d1 --- /dev/null +++ b/src/components/playlist/PlaylistDumpList.tsx @@ -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; + 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(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + if (dumps.length === 0) { + return ( +

      + No dumps in this playlist yet. +

      + ); + } + + 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 ( +
      e.preventDefault() : undefined} + > + {dumps.map((dump) => { + const phase = fading[dump.id]; + const index = phase ? -1 : activeIndex++; + const draggable = canDrag && !phase; + return ( +
      { + dragSrcRef.current = index; + } + : undefined} + onDragOver={draggable ? (e) => handleDragOver(e, index) : undefined} + onDragEnd={canDrag ? handleDragEnd : undefined} + > + {draggable && ⠿} + + {!phase && onRemove && ( + + )} + {phase === "cooldown" && onUndoRemove && ( + + )} +
      + ); + })} +
      + ); +} diff --git a/src/components/playlist/PlaylistEditForm.tsx b/src/components/playlist/PlaylistEditForm.tsx new file mode 100644 index 00000000..47396661 --- /dev/null +++ b/src/components/playlist/PlaylistEditForm.tsx @@ -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(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({ + 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>( + 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>( + await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, { + method: "POST", + body: fd, + }), + ), + )); + return; + } + + onSaved(updated); + }, + ); + + return ( + + { + /* `display: contents` keeps the header's layout, the cover beside the + fields, around a real
      , which Enter submits. */ + } + + ( + { + field.onChange(file); + setImagePreview(URL.createObjectURL(file)); + }} + /> + )} + /> + +
      + ( + { + if (e.key === "Escape") onCancel(); + }} + autoFocus + placeholder={t`Playlist title`} + maxLength={VALIDATION.PLAYLIST_TITLE_MAX} + /> + )} + /> + + ( + void onSubmit()} + onCancel={onCancel} + placeholder={t`Description (optional)`} + autoResize + rows={2} + maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX} + /> + )} + /> + + ( + + )} + /> + + + +
      + +
      + + Saving…} + disabled={!title.trim() || + description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX} + > + Save + +
      +
      +
      + +
      + ); +} diff --git a/src/components/playlist/PlaylistHeader.tsx b/src/components/playlist/PlaylistHeader.tsx new file mode 100644 index 00000000..25faeb8b --- /dev/null +++ b/src/components/playlist/PlaylistHeader.tsx @@ -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 && ( + + )} +
      +
      +

      {playlist.title}

      + {user?.id !== playlist.userId && ( + + )} + {onEdit && ( + + )} +
      + + {playlist.description && ( + + {playlist.description} + + )} + +
      + + {playlist.isPublic ? public : private} + + {playlist.ownerUsername && ( + + @{playlist.ownerUsername} + + )} + + + + {playlist.updatedAt && ( + + + edited {relativeTime(playlist.updatedAt)} + + + )} +
      +
      + + ); +} diff --git a/src/components/profile/DescriptionEditor.tsx b/src/components/profile/DescriptionEditor.tsx index 4f82ffd3..6db3d014 100644 --- a/src/components/profile/DescriptionEditor.tsx +++ b/src/components/profile/DescriptionEditor.tsx @@ -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>( await authFetch(`${API_URL}/api/users/me`, { method: "PATCH", diff --git a/src/components/profile/ProfileDumpsTab.tsx b/src/components/profile/ProfileDumpsTab.tsx index 3e9bc1bc..1d4a02e4 100644 --- a/src/components/profile/ProfileDumpsTab.tsx +++ b/src/components/profile/ProfileDumpsTab.tsx @@ -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; votes: PagedList; 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; } diff --git a/src/components/profile/ProfileFollowedTab.tsx b/src/components/profile/ProfileFollowedTab.tsx index 5ea16a1e..589b2719 100644 --- a/src/components/profile/ProfileFollowedTab.tsx +++ b/src/components/profile/ProfileFollowedTab.tsx @@ -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 */ diff --git a/src/components/profile/ProfileInviteesTab.tsx b/src/components/profile/ProfileInviteesTab.tsx index 94bf8c6a..ee319290 100644 --- a/src/components/profile/ProfileInviteesTab.tsx +++ b/src/components/profile/ProfileInviteesTab.tsx @@ -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 */ diff --git a/src/components/profile/ProfileManageTab.tsx b/src/components/profile/ProfileManageTab.tsx index 56c3ad58..6ac7366a 100644 --- a/src/components/profile/ProfileManageTab.tsx +++ b/src/components/profile/ProfileManageTab.tsx @@ -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 */ diff --git a/src/components/profile/ProfileOwnActions.tsx b/src/components/profile/ProfileOwnActions.tsx index 60f08372..6ea7b952 100644 --- a/src/components/profile/ProfileOwnActions.tsx +++ b/src/components/profile/ProfileOwnActions.tsx @@ -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 */ diff --git a/src/components/profile/ProfilePlaylistsTab.tsx b/src/components/profile/ProfilePlaylistsTab.tsx index f8207a65..cd5e3955 100644 --- a/src/components/profile/ProfilePlaylistsTab.tsx +++ b/src/components/profile/ProfilePlaylistsTab.tsx @@ -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; 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; } diff --git a/src/components/profile/ProfileSection.tsx b/src/components/profile/ProfileSection.tsx index de07a4b6..2be0c12d 100644 --- a/src/components/profile/ProfileSection.tsx +++ b/src/components/profile/ProfileSection.tsx @@ -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; diff --git a/src/components/profile/ProfileSettingsTab.tsx b/src/components/profile/ProfileSettingsTab.tsx index d9ba562e..3fbfa262 100644 --- a/src/components/profile/ProfileSettingsTab.tsx +++ b/src/components/profile/ProfileSettingsTab.tsx @@ -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 */ diff --git a/src/components/profile/SettingRow.tsx b/src/components/profile/SettingRow.tsx index 6dec2d18..0a65f53f 100644 --- a/src/components/profile/SettingRow.tsx +++ b/src/components/profile/SettingRow.tsx @@ -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; } diff --git a/src/components/shared/LoadMoreButton.tsx b/src/components/shared/LoadMoreButton.tsx index 9465bb30..691806d9 100644 --- a/src/components/shared/LoadMoreButton.tsx +++ b/src/components/shared/LoadMoreButton.tsx @@ -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; diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx index 9c431035..7b8fe1aa 100644 --- a/src/components/social/FollowButton.tsx +++ b/src/components/social/FollowButton.tsx @@ -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. */ diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx index cc501276..482a740a 100644 --- a/src/components/social/UserListPopover.tsx +++ b/src/components/social/UserListPopover.tsx @@ -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`, without a query - * string: the popover appends `page` and `limit`. + * It answers `PaginatedData`, 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({ 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(null); const popoverRef = useRef(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>>( - 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>>( - 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" && (

      {state.error}

      )} - {state.status === "loaded" && state.users.length === 0 && ( + {state.status === "loaded" && state.data.items.length === 0 && (

      No one yet.

      )} - {state.status === "loaded" && state.users.length > 0 && ( + {state.status === "loaded" && state.data.items.length > 0 && ( <> - {state.users.map((u) => ( + {state.data.items.map((u) => ( {u.username} ))} - {state.hasMore && ( + {state.data.hasMore && ( )} diff --git a/src/contexts/category/CategoriesProvider.tsx b/src/contexts/category/CategoriesProvider.tsx index b56a1647..3bd97d4b 100644 --- a/src/contexts/category/CategoriesProvider.tsx +++ b/src/contexts/category/CategoriesProvider.tsx @@ -22,7 +22,7 @@ import { deserializeCategory } from "../../utils/shared/deserialize.ts"; import type { Wire } from "../../../shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts"; -/** How long a failed first fetch waits before it is tried again. */ +// A failed first fetch waits this long before it is tried again. const RETRY_MS = 10_000; /** diff --git a/src/contexts/social/FollowContext.ts b/src/contexts/social/FollowContext.ts index d7909907..dc171cee 100644 --- a/src/contexts/social/FollowContext.ts +++ b/src/contexts/social/FollowContext.ts @@ -28,7 +28,10 @@ export interface FollowContextValue { isLoaded: boolean; } -/** Given to a consumer outside `FollowProvider`: nothing is followed. */ +/** + * Given to a consumer outside `FollowProvider`, for which nothing is + * followed. + */ export const defaultFollowValue: FollowContextValue = { followedUserIds: new Set(), followedPlaylistIds: new Set(), diff --git a/src/contexts/social/FollowProvider.tsx b/src/contexts/social/FollowProvider.tsx index d55a4416..14700b81 100644 --- a/src/contexts/social/FollowProvider.tsx +++ b/src/contexts/social/FollowProvider.tsx @@ -23,7 +23,7 @@ export interface FollowProviderProps { children: ReactNode; } -/** How long a failed fetch of the follows waits before it is tried again. */ +// A failed fetch of the follows waits this long before it is tried again. const RETRY_MS = 10_000; /** diff --git a/src/contexts/theme/ThemeContext.ts b/src/contexts/theme/ThemeContext.ts index 1ad86e27..dcf94126 100644 --- a/src/contexts/theme/ThemeContext.ts +++ b/src/contexts/theme/ThemeContext.ts @@ -8,9 +8,9 @@ import { createContext } from "react"; /** - * Every site style, which `ThemeProvider` checks a stored choice against. Each - * has a stylesheet under `src/themes/`, keyed on the root element's - * `data-style`, which is absent for "smooth". + * Lists every site style, which `ThemeProvider` checks a stored choice + * against. Each has a stylesheet under `src/themes/`, keyed on the root + * element's `data-style`, which is absent for "smooth". */ export const STYLE_NAMES = [ "smooth", @@ -20,12 +20,12 @@ export const STYLE_NAMES = [ "kanagawa", "melange", ] as const; -/** One of {@link STYLE_NAMES}. */ +/** Held by `ThemeProvider` for the style chosen in this browser. */ export type StyleName = typeof STYLE_NAMES[number]; -/** Every color scheme; "auto" follows the system's. */ +/** Lists every color scheme. "auto" follows the system's. */ export const COLOR_SCHEMES = ["auto", "light", "dark"] as const; -/** One of {@link COLOR_SCHEMES}. */ +/** Held by `ThemeProvider` for the color scheme chosen in this browser. */ export type ColorScheme = typeof COLOR_SCHEMES[number]; /** diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts index 78614510..e0f2d348 100644 --- a/src/contexts/ws/WSContext.ts +++ b/src/contexts/ws/WSContext.ts @@ -140,14 +140,14 @@ export interface WSContextValue { */ recentDumps: Dump[]; /** - * Dumps deleted this session, or made private by someone else, for the - * lists to filter out. A dump made public again leaves it. + * It holds the dumps deleted this session, or made private by someone else, + * for the lists to filter out. A dump made public again leaves it. */ deletedDumpIds: Set; lastVoteEvent: VoteEvent | null; /** - * The dump as its latest `dump_created` or `dump_updated` has it, for the - * lists to update it in place, or put it back where it was. + * It holds the dump as its latest `dump_created` or `dump_updated` has it, + * for the lists to update it in place, or put it back where it was. */ lastDumpEvent: Dump | null; lastPlaylistEvent: PlaylistEvent | null; diff --git a/src/contexts/ws/WSProvider.tsx b/src/contexts/ws/WSProvider.tsx index 2bd589ce..3ba2a397 100644 --- a/src/contexts/ws/WSProvider.tsx +++ b/src/contexts/ws/WSProvider.tsx @@ -93,13 +93,15 @@ const CHAT_READ_THROTTLE = 1_000; */ const CHAT_ECHO_WINDOW = 60_000; -/** Toggles of one item that the server hasn't acknowledged yet. */ +/** + * Held by {@link useOptimisticToggles} for an item whose toggles the server + * hasn't all acknowledged yet. `count` and `on` are as before the first of + * them, for a failure to restore. + */ interface PendingToggle { timeout: ReturnType; - /** The count and state before the first of them, to restore on failure. */ count: number; on: boolean; - /** How many acks are still to come. */ outstanding: number; } @@ -117,7 +119,7 @@ interface OptimisticToggles { /** Restores the item as it was before its toggles, now. */ fail: (id: string) => void; /** - * Settles every toggle against the server's set after a (re)connect: each + * Settles every toggle against the server's set after a (re)connect. Each * count moves by one from its baseline when the server's state differs. */ reconcile: (serverOn: Set) => void; @@ -265,7 +267,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const [avatarOverrides, setAvatarOverrides] = useState< Record >({}); - // Never reset: pages compare it with the value they last saw. + // It is never reset, since pages compare it with the value they last saw. const [connectionEpoch, setConnectionEpoch] = useState(0); const [followsVersion, setFollowsVersion] = useState(0); diff --git a/src/hooks/dump/useDumpListSync.ts b/src/hooks/dump/useDumpListSync.ts index 8534d325..bcaec68b 100644 --- a/src/hooks/dump/useDumpListSync.ts +++ b/src/hooks/dump/useDumpListSync.ts @@ -10,8 +10,8 @@ import type { Dump } from "../../../shared/dump.ts"; import { useWS } from "../ws/useWS.ts"; /** - * Passed to {@link useDumpListSync} by a profile's own list of dumps. The - * feeds pass none. + * Passed to {@link useDumpListSync} by a profile's own list of dumps, and by + * `usePlaylist` for the playlist's owner. The feeds pass none. */ export interface DumpListSyncOptions { /** Keeps an edited dump that is now private, for its owner's own list. */ @@ -23,7 +23,8 @@ export interface DumpListSyncOptions { * updates. A deleted dump is removed, an edited one is replaced in place, and * a new or deleted comment moves its dump's `commentCount`. It never adds a * dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the - * profiles put back a dump that returns with `usePositionAwareSync`. + * profiles and `usePlaylist` put back a dump that returns with + * `usePositionAwareSync`. */ export function useDumpListSync( setDumps: (fn: (prev: Dump[]) => Dump[]) => void, diff --git a/src/hooks/dump/useUpvotedList.ts b/src/hooks/dump/useUpvotedList.ts index bb78d4fc..0109778a 100644 --- a/src/hooks/dump/useUpvotedList.ts +++ b/src/hooks/dump/useUpvotedList.ts @@ -17,25 +17,27 @@ import { fadeClassName, useFading } from "../feed/useFading.ts"; /** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */ export interface UseUpvotedListOptions { - /** Whose upvotes the list holds, null while their profile loads. */ + /** + * The list holds this user's upvotes. It is null while their profile loads. + */ profileUserId: string | null; /** Does nothing until the list is loaded. */ setItems: (fn: (prev: Dump[]) => Dump[]) => void; /** - * Dumps already at hand, which a vote of the signed-in user on their own - * list puts first at once, before the socket's vote event. The profile - * passes its first page of the user's dumps. + * It holds dumps already at hand, which a vote of the signed-in user on + * their own list puts first at once, before the socket's vote event. The + * profile passes its first page of the user's dumps. */ source?: Dump[]; } /** Returned by {@link useUpvotedList}. */ export interface UseUpvotedListResult { - /** For `DumpCardList`'s `cardClassName`. */ + /** It goes to `DumpCardList`'s `cardClassName`. */ cardClassName: (dump: Dump) => string | undefined; /** - * Whether the vote on dump `id` was withdrawn since the list loaded. The - * profile's `usePositionAwareSync` keeps such a dump from coming back. + * Checks whether the vote on dump `id` was withdrawn since the list loaded. + * The profile's `usePositionAwareSync` keeps such a dump from coming back. */ isWithdrawn: (id: string) => boolean; } @@ -87,9 +89,10 @@ export function useUpvotedList( if (!isOwn) return; const prev = prevMyVotesRef.current; prevMyVotesRef.current = new Set(myVotes); - // Only a baseline: the list as loaded stands. myVotes may still be the - // empty set from before the socket's welcome (or the socket may never - // connect), and taking it as the truth here would hide every dump. + // This is only a baseline, and the list as loaded stands. myVotes may + // still be the empty set from before the socket's welcome (or the socket + // may never connect), and taking it as the truth here would hide every + // dump. if (prev === null) return; for (const id of prev) if (!myVotes.has(id)) withdraw(id); const added = new Set([...myVotes].filter((id) => !prev.has(id))); diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts index e9fdfa2f..460543fe 100644 --- a/src/hooks/dump/useUserDumpFeed.ts +++ b/src/hooks/dump/useUserDumpFeed.ts @@ -7,12 +7,20 @@ import { type RefCallback, useCallback, useEffect, useState } from "react"; -import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; -import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; +import { API_URL } from "../../config/api.ts"; +import { friendlyFetchError } from "../../utils/shared/api.ts"; import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; -import { fetchFirstPage } from "../../utils/feed/pagedList.ts"; +import { + canLoadMore, + mapItems, + type PagedList, + pageUrl, + readFirstPage, + readPage, + restoredList, +} from "../../utils/feed/pagedList.ts"; +import type { LoadState } from "../../utils/shared/loadState.ts"; import type { Dump } from "../../../shared/dump.ts"; -import type { PaginatedData, Wire } from "../../../shared/api.ts"; import type { PublicUser } from "../../../shared/user.ts"; import { deserializeDump, @@ -21,6 +29,7 @@ import { import { useAuth } from "../auth/useAuth.ts"; import { useFeedCache } from "../feed/useFeedCache.ts"; import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts"; +import { useLoadMore } from "../feed/usePagedList.ts"; import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; /** @@ -28,24 +37,20 @@ import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; * when the user doesn't exist, and holds an empty list when only the dumps * failed to load. */ -export type UserDumpFeedState = - | { status: "loading" } - | { status: "error"; error: string } - | { - status: "loaded"; - profileUser: PublicUser; - items: Dump[]; - hasMore: boolean; - page: number; - loadingMore: boolean; - }; +export type UserDumpFeedState = LoadState<{ + profileUser: PublicUser; + dumps: PagedList; +}>; /** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */ export interface UseUserDumpFeedResult { state: UserDumpFeedState; - /** Does nothing until loaded. Passed to `useDumpListSync` and `useUpvotedList`. */ + /** + * It does nothing until loaded. It goes to `useDumpListSync` and + * `useUpvotedList`. + */ setItems: (fn: (prev: Dump[]) => Dump[]) => void; - /** From `useInfiniteScroll`, for an element after the last card. */ + /** It comes from `useInfiniteScroll`, for an element after the last card. */ sentinelRef: RefCallback; } @@ -62,7 +67,7 @@ export function useUserDumpFeed( endpoint: string, cacheKey: string, ): UseUserDumpFeedResult { - const { token } = useAuth(); + const { authFetch } = useAuth(); const { cached, saveState } = useFeedCache(cacheKey, hydrateDump); const [state, setState] = useState({ status: "loading" }); @@ -72,9 +77,23 @@ export function useUserDumpFeed( setState({ status: "loading" }); } - const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => { - setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) }); - }, []); + const setDumps = useCallback( + (fn: (list: PagedList) => PagedList) => + setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }), + [], + ); + const setItems = useCallback( + (fn: (prev: Dump[]) => Dump[]) => setDumps(mapItems(fn)), + [setDumps], + ); + + const url = `${API_URL}/api/users/${username}/${endpoint}`; + const fetchPage = useCallback( + (page: number, signal?: AbortSignal) => + authFetch(pageUrl(url, page), { signal }) + .then((res) => readPage(res, deserializeDump)), + [authFetch, url], + ); useEffect(() => { if (!username) return; @@ -82,72 +101,31 @@ export function useUserDumpFeed( const { signal } = controller; // Back on the page, the list comes from the cache and only the user is // fetched again. - const list = cached ?? fetchFirstPage( - `${API_URL}/api/users/${username}/${endpoint}`, - deserializeDump, - { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal }, - ); + const list = cached + ? restoredList(cached) + : authFetch(pageUrl(url, 1), { signal }) + .then((res) => readFirstPage(res, deserializeDump)); Promise.all([fetchPublicUser(username, signal), list]) - .then(([profileUser, { items, hasMore, page }]) => - setState({ - status: "loaded", - profileUser, - items, - hasMore, - page, - loadingMore: false, - }) + .then(([profileUser, dumps]) => + setState({ status: "loaded", profileUser, dumps }) ) .catch((err) => { if (signal.aborted) return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); - }, [username, endpoint, cached, token]); - - const loadMore = useCallback(() => { - if ( - state.status !== "loaded" || !state.hasMore || state.loadingMore || - !username - ) return; - const nextPage = state.page + 1; - setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); - fetch( - `${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, - { headers: token ? { Authorization: `Bearer ${token}` } : {} }, - ) - .then((res) => expectOk>>(res)) - .then(({ items, hasMore }) => { - const newItems = items.map(deserializeDump); - setState((s) => - s.status === "loaded" - ? { - ...s, - items: [...s.items, ...newItems], - hasMore, - page: nextPage, - loadingMore: false, - } - : s - ); - }) - .catch(() => - setState((s) => - s.status === "loaded" ? { ...s, loadingMore: false } : s - ) - ); - }, [state, username, token, endpoint]); + }, [username, cached, authFetch, url]); + const list = state.status === "loaded" ? state.dumps : null; const sentinelRef = useInfiniteScroll( - loadMore, - state.status === "loaded" && state.hasMore && !state.loadingMore, + useLoadMore(list, setDumps, fetchPage), + canLoadMore(list), ); useScrollSave( state.status === "loaded", useCallback((y) => { - if (state.status !== "loaded") return; - saveState(state.items, state.page, state.hasMore, y); + if (state.status === "loaded") saveState(state.dumps, y); }, [state, saveState]), ); diff --git a/src/hooks/feed/useDefaultFeedTab.ts b/src/hooks/feed/useDefaultFeedTab.ts index 681cd0f8..33cdddf6 100644 --- a/src/hooks/feed/useDefaultFeedTab.ts +++ b/src/hooks/feed/useDefaultFeedTab.ts @@ -13,10 +13,10 @@ import { const STORAGE_KEY = "default-feed-tab"; -/** The components reading the tab, told when this tab changes it. */ +// The components reading the tab, told when this tab changes it. const listeners = new Set<() => void>(); -/** The choice made while storage is blocked, which lasts until a reload. */ +// Holds the choice made while storage is blocked, until a reload. let unstored: FeedTab | null = null; /** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */ diff --git a/src/hooks/feed/useFading.ts b/src/hooks/feed/useFading.ts index 087f046e..4ff0955e 100644 --- a/src/hooks/feed/useFading.ts +++ b/src/hooks/feed/useFading.ts @@ -14,9 +14,9 @@ import { } from "react"; /** - * The phase of a fading item, which {@link fadeClassName} maps to a CSS class. - * `cooldown` lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS - * collapse. + * Held by {@link useFading} for each fading item, and mapped to a CSS class by + * {@link fadeClassName}. `cooldown` lasts 2 seconds, and `dismissing` lasts + * the 350ms of the CSS collapse. */ export type FadeState = "cooldown" | "dismissing"; @@ -24,16 +24,16 @@ const COOLDOWN_MS = 2000; const DISMISS_MS = 350; /** - * Returned by {@link useFading} to `useUpvotedList` and `PlaylistDetail`. The + * Returned by {@link useFading} to `useUpvotedList` and `usePlaylist`. The * list keeps showing an item while it is in `fading`. */ export interface UseFadingResult { fading: Record; /** Does nothing when `id` is already fading. */ startFading: (id: string) => void; - /** Called when the removal is undone: the item stays. */ + /** Called when the removal is undone, so that the item stays. */ cancelFading: (id: string) => void; - /** Called when the list is replaced, as by another user's or a reload. */ + /** Called when the list is replaced, as by another user's upvotes. */ cancelAll: () => void; } diff --git a/src/hooks/feed/useFeedCache.ts b/src/hooks/feed/useFeedCache.ts index 47f7099f..23bcb473 100644 --- a/src/hooks/feed/useFeedCache.ts +++ b/src/hooks/feed/useFeedCache.ts @@ -8,6 +8,7 @@ import { useCallback, useState } from "react"; import { useNavigationType } from "react-router"; import { useAuth } from "../auth/useAuth.ts"; +import type { PagedList } from "../../utils/feed/pagedList.ts"; /** Past this age, a saved feed is refetched rather than restored. */ const TTL = 10 * 60 * 1000; // 10 minutes @@ -17,9 +18,9 @@ const TTL = 10 * 60 * 1000; // 10 minutes * hook when the page is next reached by going back. */ export interface FeedCacheEntry { - /** Every page loaded so far. JSON turns their dates into strings. */ + /** It holds every page loaded so far, whose dates JSON turns into strings. */ items: T[]; - /** The last page loaded, from which loading more carries on. */ + /** Loading more carries on from this page, the last loaded. */ page: number; hasMore: boolean; scrollY: number; @@ -27,20 +28,22 @@ export interface FeedCacheEntry { savedAt: number; } +/** + * Passed to {@link useFeedCache}'s `saveState` by the feed pages, from the + * list they hold, without its loading flag. + */ +export type SavedList = Pick, "items" | "page" | "hasMore">; + /** Returned by {@link useFeedCache} to a feed page. */ export interface UseFeedCacheResult { /** - * The saved feed, when the page was reached by going back or forward and the - * feed was saved less than 10 minutes ago. It changes only with the key. + * It holds the saved feed when the page was reached by going back or + * forward and the feed was saved less than 10 minutes ago. It changes only + * with the key. */ cached: Omit, "savedAt"> | null; /** Usually called through `useScrollSave`, as the page is left. */ - saveState: ( - items: T[], - page: number, - hasMore: boolean, - scrollY: number, - ) => void; + saveState: (list: SavedList, scrollY: number) => void; } /** Returns the entry saved under `key`, or null if none is fresh enough. */ @@ -89,20 +92,22 @@ export function useFeedCache( } const saveState = useCallback( - (items: T[], page: number, hasMore: boolean, scrollY: number) => { + ({ items, page, hasMore }: SavedList, scrollY: number) => { try { sessionStorage.setItem( fullKey, - JSON.stringify({ - items, - page, - hasMore, - scrollY, - savedAt: Date.now(), - }), + JSON.stringify( + { + items, + page, + hasMore, + scrollY, + savedAt: Date.now(), + } satisfies FeedCacheEntry, + ), ); } catch { - // Full or blocked storage: the page just refetches next time. + // The page just refetches next time, when storage is full or blocked. } }, [fullKey], diff --git a/src/hooks/feed/useInfiniteScroll.ts b/src/hooks/feed/useInfiniteScroll.ts index 9122962f..3d2ee7c8 100644 --- a/src/hooks/feed/useInfiniteScroll.ts +++ b/src/hooks/feed/useInfiniteScroll.ts @@ -12,8 +12,7 @@ const MAX_RETRY_DELAY_MS = 30_000; /** * Returns a callback ref for the sentinel element placed after a feed's last * item, and calls `onLoadMore` when the sentinel comes within 200px of the - * view while `enabled`. Feed pages pass `enabled` as "loaded, more to come and - * not already loading". + * view while `enabled`. Feed pages pass `canLoadMore` of their list. * * The ref is a callback so that the observer follows a new sentinel, as when * `Index` switches tabs and another feed mounts its own. diff --git a/src/hooks/feed/usePagedList.ts b/src/hooks/feed/usePagedList.ts new file mode 100644 index 00000000..1e0a4187 --- /dev/null +++ b/src/hooks/feed/usePagedList.ts @@ -0,0 +1,107 @@ +/** + * Loads a list a page at a time. Every list the API pages goes through + * {@link useLoadMore} for its next pages, and a list that loads on its own + * through {@link usePagedList}, from its first page on. + * + * @module + */ + +import { useCallback, useLayoutEffect, useRef } from "react"; +import type { PaginatedData } from "../../../shared/api.ts"; +import { + canLoadMore, + firstPage, + mapItems, + type PagedList, + restoredList, +} from "../../utils/feed/pagedList.ts"; +import type { FeedCacheEntry } from "./useFeedCache.ts"; +import { type FetchState, useFetchState } from "../shared/useFetchState.ts"; + +/** + * Passed to {@link useLoadMore} and {@link usePagedList} by a list's page or + * hook. It resolves to page `page` of the list, its items deserialized, as + * `fetch` with `pageUrl` and `readPage` gives it, and rejects when the page + * can't be had. + */ +export type FetchPage = ( + page: number, + signal?: AbortSignal, +) => Promise>; + +/** + * Returns the call that loads the page after `list`'s last one and appends it + * through `setList`. It does nothing unless `canLoadMore(list)`; a page that + * fails leaves the list as it was, for the next call to try again. The pages + * that load their lists with their user (`UserPlaylists`, `useUserDumpFeed`, + * the profile's followed users) use it directly, and `usePagedList` for the + * others. + */ +export function useLoadMore( + list: PagedList | null, + setList: (fn: (list: PagedList) => PagedList) => void, + fetchPage: FetchPage, +): () => void { + // A ref, so that a new `fetchPage` on every render keeps the call stable. + const fetchPageRef = useRef(fetchPage); + useLayoutEffect(() => { + fetchPageRef.current = fetchPage; + }); + + return useCallback(() => { + if (!list || !canLoadMore(list)) return; + const page = list.page + 1; + setList((l) => ({ ...l, loadingMore: true })); + fetchPageRef.current(page) + .then(({ items, hasMore }) => + setList((l) => ({ + items: [...l.items, ...items], + hasMore, + page, + loadingMore: false, + })) + ) + .catch(() => setList((l) => ({ ...l, loadingMore: false }))); + }, [list, setList]); +} + +/** Returned by {@link usePagedList}. */ +export interface UsePagedListResult { + state: FetchState>; + /** Does nothing until loaded. Passed to the sync hooks. */ + setItems: (fn: (items: T[]) => T[]) => void; + /** It goes to `useInfiniteScroll` or to a `LoadMoreButton`. */ + loadMore: () => void; +} + +/** + * Returns the list `fetchPage` serves for `key`, from its first page, and the + * call that loads the next. `key` changes as `useFetchState`'s does, and + * `cached`, `useFeedCache`'s restored list for it, if any, is shown without + * loading. The index's feeds, the notifications and the popover of voters and + * likers use it. + */ +export function usePagedList( + key: string | undefined, + fetchPage: FetchPage, + cached?: Omit, "savedAt"> | null, +): UsePagedListResult { + const { state, setData } = useFetchState( + key, + (signal) => + fetchPage(1, signal).then(({ items, hasMore }) => + firstPage(items, hasMore) + ), + { initial: cached ? restoredList(cached) : undefined }, + ); + const setItems = useCallback( + (fn: (items: T[]) => T[]) => setData(mapItems(fn)), + [setData], + ); + const loadMore = useLoadMore( + state.status === "loaded" ? state.data : null, + setData, + fetchPage, + ); + return { state, setItems, loadMore }; +} diff --git a/src/hooks/feed/usePositionAwareSync.ts b/src/hooks/feed/usePositionAwareSync.ts index 617d8c97..98756e58 100644 --- a/src/hooks/feed/usePositionAwareSync.ts +++ b/src/hooks/feed/usePositionAwareSync.ts @@ -1,6 +1,6 @@ /** - * Puts an item that left a profile's list, by turning private, back at its - * old position when it turns public again. + * Puts an item that left a list, as by turning private, back at its old + * position when it turns public again. * * @module */ @@ -10,8 +10,8 @@ import { useEffect, useLayoutEffect, useRef } from "react"; /** * Remembers the index of every item that leaves `items`, whatever took it * out, and puts it back there when `lastEvent` shows it again and - * `shouldReinsert` accepts it. The profile pages use it with `WSContext`'s - * latest dump or playlist event, beside `useDumpListSync` or + * `shouldReinsert` accepts it. The profile pages and `usePlaylist` use it with + * `WSContext`'s latest dump or playlist event, beside `useDumpListSync` or * `usePlaylistListSync`, which never re-add an item themselves. `items` is the * loaded list, or `[]` before it loads, and `setItems` its setter. */ diff --git a/src/hooks/playlist/usePlaylist.ts b/src/hooks/playlist/usePlaylist.ts new file mode 100644 index 00000000..01123b82 --- /dev/null +++ b/src/hooks/playlist/usePlaylist.ts @@ -0,0 +1,334 @@ +/** + * Loads a playlist with its dumps for its page, keeps them live, and makes the + * changes its owner or a moderator makes there. + * + * @module + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useNavigate } from "react-router"; +import { t } from "@lingui/core/macro"; +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { Dump } from "../../../shared/dump.ts"; +import type { + Playlist, + PlaylistWithDumps, + ReorderPlaylistRequest, +} from "../../../shared/playlist.ts"; +import { + deserializeDump, + deserializePlaylistWithDumps, +} from "../../utils/shared/deserialize.ts"; +import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; +import { playlistUrl } from "../../utils/shared/urls.ts"; +import { useAuth } from "../auth/useAuth.ts"; +import { useWS } from "../ws/useWS.ts"; +import { type FetchState, useFetchState } from "../shared/useFetchState.ts"; +import { type FadeState, useFading } from "../feed/useFading.ts"; +import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts"; +import { useDumpListSync } from "../dump/useDumpListSync.ts"; + +/** Returned by {@link usePlaylist} to `PlaylistDetail`. */ +export interface UsePlaylistResult { + state: FetchState; + /** + * It holds the dumps being removed, which stay listed while they fade. A + * dump in it can't be dragged, and during its `cooldown` its removal can be + * undone. + */ + fading: Record; + /** It says why the server refused a removal, a reorder or a delete. */ + actionError: string | null; + /** Shows the playlist as `PlaylistEditForm` saved it. */ + applyDetails: (playlist: Playlist) => void; + /** + * Fades the dump out, and takes it out of the playlist on the server when + * the fade ends, or when the page is left before. + */ + remove: (dumpId: string) => void; + /** Keeps a dump that `remove` is fading out. Nothing was sent yet. */ + undoRemove: (dumpId: string) => void; + /** + * Moves the dump at `from` to `to`, counted among the dumps not fading, on + * screen only, as a drag goes along. + */ + move: (from: number, to: number) => void; + /** Sends the order `move` left, as a drag ends. */ + saveOrder: () => void; + /** Leaves for home once the playlist is deleted, or sets `actionError`. */ + deletePlaylist: () => Promise; +} + +/** + * Returns `items` with those that `ids` names put in its order, in the slots + * those items held. The others, as the dumps fading out, keep their slots. + */ +function inOrder(items: T[], ids: string[]): T[] { + const byId = new Map(items.map((i) => [i.id, i])); + const ordered = ids.filter((id) => byId.has(id)).map((id) => byId.get(id)!); + const named = new Set(ordered.map((i) => i.id)); + let next = 0; + return items.map((i) => named.has(i.id) ? ordered[next++] : i); +} + +/** Returns `playlist` with the details of `updated`, keeping its dumps. */ +function withDetails( + playlist: PlaylistWithDumps, + updated: Playlist, +): PlaylistWithDumps { + return { + ...playlist, + ...updated, + // Set only where the dumps were counted, which an edit's answer isn't. + ownerUsername: updated.ownerUsername ?? playlist.ownerUsername, + dumps: playlist.dumps, + }; +} + +/** + * Returns the playlist `playlistId` (its id or its slug) from + * `GET /api/playlists/:playlistId`, with the dumps the viewer may see in it. + * The socket's events keep its details and dumps live: a dump another client + * removes fades out, one it adds is fetched, a new order is applied, one that + * goes private leaves and comes back at its place, and a deleted playlist + * sends the page home. A new slug replaces the URL. + */ +export function usePlaylist( + playlistId: string | undefined, +): UsePlaylistResult { + const navigate = useNavigate(); + const { user, authFetch } = useAuth(); + const { lastDumpEvent, lastPlaylistEvent } = useWS(); + const [actionError, setActionError] = useState(null); + + // The server's last order of the dumps this viewer may see, which a refused + // reorder goes back to. + const serverOrderRef = useRef([]); + const { state, setData } = useFetchState( + playlistId && `${playlistId}:${user?.id ?? "guest"}`, + (signal) => + authFetch(`${API_URL}/api/playlists/${playlistId}`, { signal }) + .then(expectOk>) + .then((wire) => { + const playlist = deserializePlaylistWithDumps(wire); + serverOrderRef.current = playlist.dumps.map((d) => d.id); + return playlist; + }), + ); + const playlist = state.status === "loaded" ? state.data : null; + // The socket names playlists by id, while the URL may hold the slug. + const id = playlist?.id; + const url = `${API_URL}/api/playlists/${id}`; + + const setDumps = useCallback( + (fn: (dumps: Dump[]) => Dump[]) => + setData((p) => ({ ...p, dumps: fn(p.dumps) })), + [setData], + ); + + // This viewer's removals whose fade hasn't ended, with the dump and its + // place, to put it back if the server refuses. + const pendingRef = useRef(new Map()); + // Every dump taken out of the playlist, by anyone, which mustn't come back + // when it next changes, as one that went private does. + const removedRef = useRef(new Set()); + + const sendRemoval = useCallback((dumpId: string) => { + const pending = pendingRef.current.get(dumpId); + if (!pending) return; + pendingRef.current.delete(dumpId); + // `keepalive`: it may be sent as the page is left. + authFetch(`${url}/dumps/${dumpId}`, { method: "DELETE", keepalive: true }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }) + .catch(() => { + removedRef.current.delete(dumpId); + setDumps((dumps) => { + if (dumps.some((d) => d.id === dumpId)) return dumps; + const next = [...dumps]; + next.splice(Math.min(pending.index, next.length), 0, pending.dump); + return next; + }); + setActionError(t`The dump couldn't be removed. Try again.`); + }); + }, [authFetch, url, setDumps]); + + const { fading, startFading, cancelFading } = useFading((dumpId) => { + setDumps((dumps) => dumps.filter((d) => d.id !== dumpId)); + sendRemoval(dumpId); + }); + + // For the effects, which must run on new events only, and the calls below, + // which must stay stable. + const latestRef = useRef({ + playlist, + fading, + startFading, + cancelFading, + sendRemoval, + }); + useLayoutEffect(() => { + latestRef.current = { + playlist, + fading, + startFading, + cancelFading, + sendRemoval, + }; + }); + + useEffect(() => { + const flush = () => + pendingRef.current.forEach((_, dumpId) => + latestRef.current.sendRemoval(dumpId) + ); + globalThis.addEventListener("pagehide", flush); + return () => { + globalThis.removeEventListener("pagehide", flush); + flush(); + }; + }, []); + + const applyDetails = useCallback((updated: Playlist) => { + const slug = latestRef.current.playlist?.slug; + setData((p) => withDetails(p, updated)); + if (updated.slug && updated.slug !== slug) { + navigate(playlistUrl(updated), { replace: true }); + } + }, [setData, navigate]); + + useEffect(() => { + const ev = lastPlaylistEvent; + if (!ev || !id || ev.playlistId !== id) return; + if (ev.type === "updated" && ev.playlist) { + applyDetails(ev.playlist); + } else if (ev.type === "deleted") { + navigate("/"); + } else if (ev.type === "dumps_updated" && ev.dumpIds) { + const ids = ev.dumpIds; + const { playlist, startFading, cancelFading } = latestRef.current; + serverOrderRef.current = ids; + const listed = new Set(ids); + const shown = new Set(playlist?.dumps.map((d) => d.id)); + for (const dumpId of shown) { + if (listed.has(dumpId)) continue; + removedRef.current.add(dumpId); + startFading(dumpId); + } + for (const dumpId of ids) { + // This viewer's own removal, which the server hasn't had yet. + if (pendingRef.current.has(dumpId)) continue; + removedRef.current.delete(dumpId); + if (shown.has(dumpId)) { + // Put back while it was fading out. + cancelFading(dumpId); + continue; + } + authFetch(`${API_URL}/api/dumps/${dumpId}`) + .then(expectOk>) + .then((wire) => { + const dump = deserializeDump(wire); + setDumps((dumps) => + dumps.some((d) => d.id === dump.id) + ? dumps + : inOrder([...dumps, dump], serverOrderRef.current) + ); + }) + .catch(() => {}); + } + setDumps((dumps) => inOrder(dumps, ids)); + } + }, [lastPlaylistEvent, id, applyDetails, navigate, authFetch, setDumps]); + + // Before `useDumpListSync`: it notes a dump's place before the sync takes + // it out, as when it goes private. + usePositionAwareSync( + playlist?.dumps ?? [], + setDumps, + lastDumpEvent, + (d) => !d.isPrivate && !removedRef.current.has(d.id), + ); + // A private dump is listed only to the playlist's owner, and only their own, + // which is the only private dump whose changes reach them. + useDumpListSync(setDumps, { + isOwner: !!user && user.id === playlist?.userId, + }); + + const remove = useCallback((dumpId: string) => { + const dumps = latestRef.current.playlist?.dumps ?? []; + const index = dumps.findIndex((d) => d.id === dumpId); + if (index === -1) return; + pendingRef.current.set(dumpId, { dump: dumps[index], index }); + removedRef.current.add(dumpId); + startFading(dumpId); + }, [startFading]); + + const undoRemove = useCallback((dumpId: string) => { + pendingRef.current.delete(dumpId); + removedRef.current.delete(dumpId); + cancelFading(dumpId); + }, [cancelFading]); + + const move = useCallback((from: number, to: number) => { + const { fading } = latestRef.current; + setDumps((dumps) => { + const ids = dumps.filter((d) => !(d.id in fading)).map((d) => d.id); + const [moved] = ids.splice(from, 1); + ids.splice(to, 0, moved); + return inOrder(dumps, ids); + }); + }, [setDumps]); + + const saveOrder = useCallback(() => { + // The server wants every dump it lists to this viewer, those whose + // removal isn't sent yet included. + const onServer = new Set(serverOrderRef.current); + const dumpIds = (latestRef.current.playlist?.dumps ?? []) + .map((d) => d.id) + .filter((dumpId) => onServer.has(dumpId)); + authFetch(`${url}/order`, { + method: "PUT", + body: JSON.stringify({ dumpIds } satisfies ReorderPlaylistRequest), + }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }) + .catch(() => { + setDumps((dumps) => inOrder(dumps, serverOrderRef.current)); + setActionError(t`The new order couldn't be saved. Try again.`); + }); + }, [authFetch, url, setDumps]); + + const deletePlaylist = useCallback(async () => { + setActionError(null); + try { + const res = await authFetch(url, { method: "DELETE" }); + // expectOk throws the server's message. + if (!res.ok) await expectOk(res); + } catch (err) { + setActionError(friendlyFetchError(err)); + return; + } + navigate("/"); + }, [authFetch, url, navigate]); + + return { + state, + fading, + actionError, + applyDetails, + remove, + undoRemove, + move, + saveOrder, + deletePlaylist, + }; +} diff --git a/src/hooks/profile/useProfile.ts b/src/hooks/profile/useProfile.ts index 6fc38c40..b9cbe84c 100644 --- a/src/hooks/profile/useProfile.ts +++ b/src/hooks/profile/useProfile.ts @@ -22,8 +22,9 @@ import { import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; import type { LoadState } from "../../utils/shared/loadState.ts"; import { - fetchFirstPage, type PagedList, + pageUrl, + readFirstPage, restoredList, } from "../../utils/feed/pagedList.ts"; import { useAuth } from "../auth/useAuth.ts"; @@ -36,8 +37,8 @@ import { useUpvotedList } from "../dump/useUpvotedList.ts"; import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts"; /** - * The profile's lists, each holding its first page only: each has its own page - * for the rest. + * Loaded by {@link useProfile} for `UserPublicProfile`. Each list holds its + * first page only, and has its own page for the rest. */ export interface ProfileData { user: PublicUser; @@ -49,13 +50,19 @@ export interface ProfileData { /** Returned by {@link useProfile} to `UserPublicProfile`. */ export interface UseProfileResult { state: LoadState; - /** Whether the signed-in user is the profile's. False while it loads. */ + /** It is false while the profile loads. */ isOwnProfile: boolean; - /** For the header's edits, which the server echoes to other browsers. */ + /** It shows the header's edits, which the server echoes to other browsers. */ setUser: (fn: (user: PublicUser) => PublicUser) => void; - /** For `NewPlaylistButton`, whose playlist the socket echoes later. */ + /** + * It shows the playlist `NewPlaylistButton` made, which the socket echoes + * later. + */ setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; - /** From `useUpvotedList`, for the upvoted list's `DumpCardList`. */ + /** + * It comes from `useUpvotedList`, and goes to the upvoted list's + * `DumpCardList`. + */ upvotedCardClassName: (dump: Dump) => string | undefined; } @@ -81,7 +88,7 @@ function updateList( * refetching only the user. */ export function useProfile(username: string | undefined): UseProfileResult { - const { user: me, token } = useAuth(); + const { user: me, authFetch } = useAuth(); const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS(); const dumpsCache = useFeedCache( @@ -174,14 +181,10 @@ export function useProfile(username: string | undefined): UseProfileResult { if (!username) return; const controller = new AbortController(); const { signal } = controller; - const headers: HeadersInit = token - ? { Authorization: `Bearer ${token}` } - : {}; const firstPageOf = (list: string, deserialize: (w: Wire) => T) => - fetchFirstPage(`${API_URL}/api/users/${username}/${list}`, deserialize, { - headers, + authFetch(pageUrl(`${API_URL}/api/users/${username}/${list}`, 1), { signal, - }); + }).then((res) => readFirstPage(res, deserialize)); // Back on the page, the lists come from the cache and only the user is // fetched again. @@ -206,7 +209,7 @@ export function useProfile(username: string | undefined): UseProfileResult { setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); - }, [username, cachedDumps, cachedVotes, cachedPlaylists, token]); + }, [username, cachedDumps, cachedVotes, cachedPlaylists, authFetch]); const saveDumps = dumpsCache.saveState; const saveVotes = votesCache.saveState; @@ -216,9 +219,9 @@ export function useProfile(username: string | undefined): UseProfileResult { useCallback((y) => { if (state.status !== "loaded") return; const { dumps, votes, playlists } = state; - saveDumps(dumps.items, dumps.page, dumps.hasMore, y); - saveVotes(votes.items, votes.page, votes.hasMore, y); - savePlaylists(playlists.items, playlists.page, playlists.hasMore, y); + saveDumps(dumps, y); + saveVotes(votes, y); + savePlaylists(playlists, y); }, [state, saveDumps, saveVotes, savePlaylists]), ); useScrollRestore(cachedDumps?.scrollY, state.status === "loaded"); diff --git a/src/hooks/shared/useFetchState.ts b/src/hooks/shared/useFetchState.ts index 7ca110f4..6c9b0db4 100644 --- a/src/hooks/shared/useFetchState.ts +++ b/src/hooks/shared/useFetchState.ts @@ -17,31 +17,58 @@ import { friendlyFetchError } from "../../utils/shared/api.ts"; /** Held by {@link useFetchState}, and passed on to `ListState`. */ export type FetchState = LoadState<{ data: T }>; +/** Passed to {@link useFetchState}. */ +export interface FetchStateOptions { + /** + * False until it should load, as the profile's tabs pass "this tab is open". + * Turning it off doesn't abort a load under way, so those tabs finish + * loading in the background. True by default. + */ + enabled?: boolean; + /** + * It holds the data already at hand for a new key, as a feed restored from + * `useFeedCache`, which starts loaded and isn't loaded again. It is read + * only when the key changes. + */ + initial?: T; +} + /** Returned by {@link useFetchState}. */ export interface UseFetchStateResult { state: FetchState; - /** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */ + /** + * Edits the loaded data, as when a next page comes in. It does nothing until + * loaded. + */ setData: (fn: (data: T) => T) => void; } /** - * Returns what `load` resolves to for `key`, loaded the first time `enabled` - * is true, and kept afterwards. Turning `enabled` off doesn't abort the load, - * so the profile's tabs, which pass "this tab is open", finish loading in the - * background. A new `key` starts over from loading and aborts the load of the - * last one, as leaving the page does; an undefined key loads nothing. `load` - * must reject with an `Error`, as `expectOk` does, whose message it shows. + * Returns what `load` resolves to for `key`, loaded the first time it is + * enabled, and kept afterwards. A new `key` starts over and aborts the load of + * the last one, as leaving the page does; an undefined key loads nothing. The + * key must change with whatever the load depends on, the viewer included when + * it is signed in. `load` must reject with an `Error`, as `expectOk` does, + * whose message it shows. */ export function useFetchState( key: string | undefined, load: (signal: AbortSignal) => Promise, - enabled = true, + { enabled = true, initial }: FetchStateOptions = {}, ): UseFetchStateResult { - const [state, setState] = useState>({ status: "loading" }); - const [prevKey, setPrevKey] = useState(key); - if (prevKey !== key) { - setPrevKey(key); - setState({ status: "loading" }); + const startState = (): FetchState => + initial === undefined + ? { status: "loading" } + : { status: "loaded", data: initial }; + const [state, setState] = useState>(startState); + // `restored`: the key's data came from `initial`, and isn't loaded. + const [current, setCurrent] = useState({ + key, + restored: initial !== undefined, + }); + if (current.key !== key) { + setCurrent({ key, restored: initial !== undefined }); + setState(startState()); } // A ref, so that a new `load` on every render doesn't load again. @@ -59,7 +86,10 @@ export function useFetchState( }, [key]); useEffect(() => { - if (!enabled || key === undefined || startedRef.current === key) return; + if ( + !enabled || key === undefined || current.restored || + startedRef.current === key + ) return; startedRef.current = key; const controller = new AbortController(); controllerRef.current = controller; @@ -69,7 +99,7 @@ export function useFetchState( if (controller.signal.aborted) return; setState({ status: "error", error: friendlyFetchError(err) }); }); - }, [enabled, key]); + }, [enabled, key, current.restored]); const setData = useCallback((fn: (data: T) => T) => { setState((s) => diff --git a/src/locales/en.js b/src/locales/en.js index e5ab9685..cf6ba234 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1,5 +1,5 @@ /*eslint-disable*/ module.exports = { messages: JSON.parse( - '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"8Ug9jB":["Related"],"8ZsakT":["Password"],"92o1-U":["Failed to delete"],"9BruTc":["Why?"],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"9xDZu_":["Could not load."],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AEfu-D":["In collections"],"AHZflp":["Chat"],"AQbgNR":["Follow ",["targetUsername"]],"AS7WoE":["Start fresh"],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"AeXO77":["Account"],"AhNP40":["Seek"],"AnRu_j":["Cancel reply"],"B4LeYP":["Delete failed (",["0"],")"],"BjitBo":["Download ",["0"]],"BlpJ2G":["Picked up where you left off."],"CI50ct":["Upvoted"],"C_qQE4":["Already dumped by ",["0"]," ",["1"]],"Cj24wt":["Registering…"],"CnqayG":["Couldn\'t update. Try again."],"D8DRYV":["Delete this message?"],"DI3NBO":["Open on ",["0"]],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"Gv5nA5":["Unexpected response format"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"]," commented on <1>",["1"],""],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"P5MuiB":["Add image"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"QctOgc":["Could not connect to the server. Check your connection and try again."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"V38P77":["Only its poster or a moderator can edit this dump."],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W5A0Ly":["An unexpected error occurred."],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"]," liked your comment on <1>",["1"],""],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"dqwCfR":["Couldn\'t load a preview for this link — check it, or post it as-is and refresh the preview later."],"dznB-Y":["Only images can be dropped here."],"eFSqvc":["Failed to post reply"],"eIEHHV":["Change skin tone"],"eOfXq3":["Title is required."],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"eloqXQ":["You can post it anyway."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"f3cEDO":["Change image"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hfk-mb":["The dump couldn\'t be put back. Try again."],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pd3FCq":["That file is over ",["limit"],"."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in"],"wtxFp4":["File too large (max ",["max"],")."],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', + '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"]," followed your playlist <1>",["1"],""],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"8Ug9jB":["Related"],"8ZsakT":["Password"],"92o1-U":["Failed to delete"],"9BruTc":["Why?"],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AEfu-D":["In collections"],"AHZflp":["Chat"],"AQbgNR":["Follow ",["targetUsername"]],"AS7WoE":["Start fresh"],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"AeXO77":["Account"],"AhNP40":["Seek"],"AnRu_j":["Cancel reply"],"B4LeYP":["Delete failed (",["0"],")"],"BjitBo":["Download ",["0"]],"BlpJ2G":["Picked up where you left off."],"CI50ct":["Upvoted"],"C_qQE4":["Already dumped by ",["0"]," ",["1"]],"Cj24wt":["Registering…"],"CnqayG":["Couldn\'t update. Try again."],"D8DRYV":["Delete this message?"],"DI3NBO":["Open on ",["0"]],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"]," upvoted <1>",["1"],""],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"Gv5nA5":["Unexpected response format"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"]," commented on <1>",["1"],""],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"P5MuiB":["Add image"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files"],"QLtPBd":["No dumps in this playlist yet."],"QctOgc":["Could not connect to the server. Check your connection and try again."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"]," started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"]," was added to <1>",["1"],""],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"V38P77":["Only its poster or a moderator can edit this dump."],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W5A0Ly":["An unexpected error occurred."],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"]," liked your comment on <1>",["1"],""],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"dqwCfR":["Couldn\'t load a preview for this link — check it, or post it as-is and refresh the preview later."],"dznB-Y":["Only images can be dropped here."],"eFSqvc":["Failed to post reply"],"eIEHHV":["Change skin tone"],"eOfXq3":["Title is required."],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"eloqXQ":["You can post it anyway."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"f3cEDO":["Change image"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"]," posted <1>",["1"],""],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"]," mentioned you in <1>",["where"],""],"pSheLH":["No invitees yet."],"pd3FCq":["That file is over ",["limit"],"."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qnUJBI":["The new order couldn\'t be saved. Try again."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in"],"wtxFp4":["File too large (max ",["max"],")."],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}', ), }; diff --git a/src/locales/en.po b/src/locales/en.po index 0685257a..28e1a1c9 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -55,7 +55,7 @@ msgstr "{0} more online: {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" -#: src/pages/Search.tsx:200 +#: src/pages/Search.tsx:120 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -68,8 +68,8 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" #: src/components/dump/create/DumpCreateModal.tsx:741 -#: src/pages/PlaylistDetail.tsx:580 -#: src/pages/UserPublicProfile.tsx:167 +#: src/pages/PlaylistDetail.tsx:80 +#: src/pages/UserPublicProfile.tsx:159 msgid "← Back" msgstr "← Back" @@ -80,7 +80,7 @@ msgid "← Back to all dumps" msgstr "← Back to all dumps" #: src/pages/UserDumps.tsx:74 -#: src/pages/UserPlaylists.tsx:306 +#: src/pages/UserPlaylists.tsx:240 #: src/pages/UserUpvoted.tsx:60 msgid "← Back to profile" msgstr "← Back to profile" @@ -99,47 +99,47 @@ msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:212 +#: src/pages/Notifications.tsx:199 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} commented on <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:172 +#: src/pages/Notifications.tsx:159 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} followed your playlist <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:222 +#: src/pages/Notifications.tsx:209 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} liked your comment on <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:238 +#: src/pages/Notifications.tsx:225 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} mentioned you in <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:182 +#: src/pages/Notifications.tsx:169 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:163 +#: src/pages/Notifications.tsx:150 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:202 +#: src/pages/Notifications.tsx:189 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:192 +#: src/pages/Notifications.tsx:179 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" @@ -147,11 +147,11 @@ msgstr "<0>{0} was added to <1>{1}" msgid "1 new message" msgstr "1 new message" -#: src/pages/Notifications.tsx:233 +#: src/pages/Notifications.tsx:220 msgid "a comment" msgstr "a comment" -#: src/pages/Notifications.tsx:236 +#: src/pages/Notifications.tsx:223 msgid "a post" msgstr "a post" @@ -228,8 +228,8 @@ msgstr "Auto" msgid "Back to login" msgstr "Back to login" -#: src/contexts/ws/WSProvider.tsx:392 -#: src/contexts/ws/WSProvider.tsx:758 +#: src/contexts/ws/WSProvider.tsx:394 +#: src/contexts/ws/WSProvider.tsx:760 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." @@ -239,16 +239,16 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may #: src/components/comment/CommentForm.tsx:127 #: src/components/dump/create/DumpCreateModal.tsx:751 #: src/components/form/FormActions.tsx:42 -#: src/components/profile/DescriptionEditor.tsx:96 +#: src/components/playlist/PlaylistEditForm.tsx:205 +#: src/components/profile/DescriptionEditor.tsx:97 #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Dump.tsx:432 #: src/pages/DumpEdit.tsx:542 -#: src/pages/PlaylistDetail.tsx:948 msgid "Cancel" msgstr "Cancel" -#: src/pages/PlaylistDetail.tsx:753 +#: src/components/playlist/PlaylistDumpList.tsx:118 msgid "Cancel removal" msgstr "Cancel removal" @@ -341,7 +341,7 @@ msgstr "Color scheme" msgid "Confirm new password" msgstr "Confirm new password" -#: src/contexts/ws/WSProvider.tsx:751 +#: src/contexts/ws/WSProvider.tsx:753 msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" @@ -362,14 +362,10 @@ msgid "Could not connect to the server. Check your connection and try again." msgstr "Could not connect to the server. Check your connection and try again." #: src/pages/DumpEdit.tsx:530 -#: src/pages/UserPlaylists.tsx:330 +#: src/pages/UserPlaylists.tsx:264 msgid "Could not delete" msgstr "Could not delete" -#: src/components/social/UserListPopover.tsx:167 -msgid "Could not load." -msgstr "Could not load." - #: src/pages/DumpEdit.tsx:490 msgid "Could not save" msgstr "Could not save" @@ -396,7 +392,7 @@ msgstr "Create & Add" #. placeholder {0}: created.items.length #. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:335 +#: src/pages/UserPlaylists.tsx:269 msgid "Created ({0}{1})" msgstr "Created ({0}{1})" @@ -423,8 +419,8 @@ msgstr "Default tab" #: src/components/chat/ChatMessageItem.tsx:213 #: src/components/comment/CommentNode.tsx:263 #: src/components/comment/CommentNode.tsx:269 +#: src/components/playlist/PlaylistEditForm.tsx:201 #: src/components/shared/ConfirmModal.tsx:34 -#: src/pages/PlaylistDetail.tsx:944 msgid "Delete" msgstr "Delete" @@ -448,8 +444,8 @@ msgid "Delete failed ({0})" msgstr "Delete failed ({0})" #: src/components/playlist/PlaylistCard.tsx:132 -#: src/pages/PlaylistDetail.tsx:766 -#: src/pages/UserPlaylists.tsx:393 +#: src/pages/PlaylistDetail.tsx:136 +#: src/pages/UserPlaylists.tsx:327 msgid "Delete playlist" msgstr "Delete playlist" @@ -465,8 +461,8 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/pages/PlaylistDetail.tsx:765 -#: src/pages/UserPlaylists.tsx:392 +#: src/pages/PlaylistDetail.tsx:135 +#: src/pages/UserPlaylists.tsx:326 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -475,7 +471,7 @@ msgid "deleted message" msgstr "deleted message" #: src/components/playlist/PlaylistCreateForm.tsx:104 -#: src/pages/PlaylistDetail.tsx:916 +#: src/components/playlist/PlaylistEditForm.tsx:173 msgid "Description (optional)" msgstr "Description (optional)" @@ -525,19 +521,19 @@ msgstr "Dump it" msgid "Dumped!" msgstr "Dumped!" -#: src/pages/Search.tsx:196 +#: src/pages/Search.tsx:116 #: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:197 +#: src/pages/UserPublicProfile.tsx:189 msgid "Dumps" msgstr "Dumps" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:36 +#: src/components/profile/ProfileDumpsTab.tsx:37 msgid "Dumps ({0}{1})" msgstr "Dumps ({0}{1})" -#: src/pages/Notifications.tsx:473 +#: src/pages/Notifications.tsx:375 msgid "Earlier" msgstr "Earlier" @@ -545,8 +541,8 @@ msgstr "Earlier" #: src/components/chat/ChatMessageItem.tsx:104 #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:254 +#: src/components/playlist/PlaylistHeader.tsx:58 #: src/pages/Dump.tsx:550 -#: src/pages/PlaylistDetail.tsx:634 msgid "Edit" msgstr "Edit" @@ -577,8 +573,8 @@ msgstr "edited" #. placeholder {0}: relativeTime(message.updatedAt) #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 +#: src/components/playlist/PlaylistHeader.tsx:93 #: src/pages/Dump.tsx:491 -#: src/pages/PlaylistDetail.tsx:673 msgid "edited {0}" msgstr "edited {0}" @@ -587,8 +583,8 @@ msgstr "edited {0}" #. placeholder {0}: message.updatedAt.toLocaleString() #: src/components/chat/ChatMessageItem.tsx:124 #: src/components/comment/CommentNode.tsx:201 +#: src/components/playlist/PlaylistHeader.tsx:91 #: src/pages/Dump.tsx:489 -#: src/pages/PlaylistDetail.tsx:670 msgid "Edited {0}" msgstr "Edited {0}" @@ -605,7 +601,7 @@ msgstr "Editing {0}" msgid "Email address" msgstr "Email address" -#: src/pages/Search.tsx:229 +#: src/pages/Search.tsx:149 msgid "Enter a query to search." msgstr "Enter a query to search." @@ -632,7 +628,7 @@ msgid "Failed to generate invite" msgstr "Failed to generate invite" #: src/components/shared/ListState.tsx:43 -#: src/pages/Notifications.tsx:447 +#: src/pages/Notifications.tsx:349 msgid "Failed to load" msgstr "Failed to load" @@ -648,9 +644,9 @@ msgstr "Failed to post comment" msgid "Failed to post reply" msgstr "Failed to post reply" -#: src/components/profile/DescriptionEditor.tsx:98 +#: src/components/playlist/PlaylistEditForm.tsx:193 +#: src/components/profile/DescriptionEditor.tsx:99 #: src/components/profile/EmailEditor.tsx:86 -#: src/pages/PlaylistDetail.tsx:936 msgid "Failed to save" msgstr "Failed to save" @@ -695,23 +691,23 @@ msgstr "Follow {targetUsername}" msgid "Follow playlist" msgstr "Follow playlist" -#: src/components/feed/FollowedFeed.tsx:215 +#: src/components/feed/FollowedFeed.tsx:151 msgid "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here." -#: src/components/feed/FollowedFeed.tsx:207 +#: src/components/feed/FollowedFeed.tsx:143 msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." #: src/components/feed/FeedTabBar.tsx:48 #: src/components/profile/ProfileSettingsTab.tsx:44 -#: src/pages/UserPublicProfile.tsx:199 +#: src/pages/UserPublicProfile.tsx:191 msgid "Followed" msgstr "Followed" #. placeholder {0}: followed.items.length #. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:366 +#: src/pages/UserPlaylists.tsx:300 msgid "Followed ({0}{1})" msgstr "Followed ({0}{1})" @@ -729,11 +725,11 @@ msgstr "Following" msgid "Forgot password?" msgstr "Forgot password?" -#: src/components/feed/FollowedFeed.tsx:196 +#: src/components/feed/FollowedFeed.tsx:132 msgid "From people" msgstr "From people" -#: src/components/feed/FollowedFeed.tsx:197 +#: src/components/feed/FollowedFeed.tsx:133 msgid "From playlists" msgstr "From playlists" @@ -789,7 +785,7 @@ msgid "invited by" msgstr "invited by" #: src/components/profile/ProfileInviteesTab.tsx:76 -#: src/pages/UserPublicProfile.tsx:200 +#: src/pages/UserPublicProfile.tsx:192 msgid "Invitees" msgstr "Invitees" @@ -818,7 +814,7 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" -#: src/contexts/ws/WSProvider.tsx:757 +#: src/contexts/ws/WSProvider.tsx:759 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" @@ -847,11 +843,11 @@ msgstr "Loading messages…" msgid "Loading more…" msgstr "Loading more…" -#: src/pages/PlaylistDetail.tsx:564 +#: src/pages/PlaylistDetail.tsx:64 msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:150 +#: src/pages/UserPublicProfile.tsx:142 msgid "Loading profile…" msgstr "Loading profile…" @@ -861,10 +857,10 @@ msgstr "Loading profile…" #: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/shared/ListState.tsx:38 #: src/components/shared/LoadMoreButton.tsx:32 -#: src/components/social/UserListPopover.tsx:224 -#: src/pages/Notifications.tsx:443 +#: src/components/social/UserListPopover.tsx:150 +#: src/pages/Notifications.tsx:345 #: src/pages/UserDumps.tsx:62 -#: src/pages/UserPlaylists.tsx:294 +#: src/pages/UserPlaylists.tsx:228 #: src/pages/UserUpvoted.tsx:48 msgid "Loading…" msgstr "Loading…" @@ -885,7 +881,7 @@ msgid "Log in to vote" msgstr "Log in to vote" #: src/components/profile/ProfileHeader.tsx:174 -#: src/pages/UserPublicProfile.tsx:171 +#: src/pages/UserPublicProfile.tsx:163 msgid "Log out" msgstr "Log out" @@ -897,7 +893,7 @@ msgstr "Logging in…" msgid "Login failed" msgstr "Login failed" -#: src/pages/UserPublicProfile.tsx:205 +#: src/pages/UserPublicProfile.tsx:197 msgid "Manage" msgstr "Manage" @@ -918,7 +914,7 @@ msgstr "Mute" msgid "Name" msgstr "Name" -#: src/pages/Notifications.tsx:436 +#: src/pages/Notifications.tsx:338 msgid "new" msgstr "new" @@ -953,15 +949,15 @@ msgstr "Next →" msgid "Next track" msgstr "Next track" -#: src/pages/PlaylistDetail.tsx:693 +#: src/components/playlist/PlaylistDumpList.tsx:46 msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." -#: src/pages/Search.tsx:246 +#: src/pages/Search.tsx:165 msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." -#: src/components/feed/DumpFeed.tsx:74 +#: src/components/feed/DumpFeed.tsx:76 #: src/components/feed/JournalFeed.tsx:36 msgid "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!" @@ -971,7 +967,7 @@ msgid "No emoji found." msgstr "No emoji found." #: src/components/profile/ProfileFollowedTab.tsx:110 -#: src/pages/UserPlaylists.tsx:375 +#: src/pages/UserPlaylists.tsx:309 msgid "No followed playlists yet." msgstr "No followed playlists yet." @@ -983,25 +979,25 @@ msgstr "No invitees yet." msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" -#: src/components/social/UserListPopover.tsx:232 +#: src/components/social/UserListPopover.tsx:158 msgid "No one yet." msgstr "No one yet." -#: src/pages/Search.tsx:288 +#: src/pages/Search.tsx:206 msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:51 -#: src/pages/UserPlaylists.tsx:344 +#: src/components/profile/ProfilePlaylistsTab.tsx:52 +#: src/pages/UserPlaylists.tsx:278 msgid "No playlists yet." msgstr "No playlists yet." -#: src/contexts/ws/WSProvider.tsx:926 +#: src/contexts/ws/WSProvider.tsx:928 msgid "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent" -#: src/pages/Search.tsx:262 +#: src/pages/Search.tsx:180 msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." @@ -1009,9 +1005,9 @@ msgstr "No users match \"{q}\"." msgid "Not following anyone yet." msgstr "Not following anyone yet." -#: src/components/profile/ProfileDumpsTab.tsx:45 -#: src/components/profile/ProfileDumpsTab.tsx:59 -#: src/pages/Notifications.tsx:454 +#: src/components/profile/ProfileDumpsTab.tsx:46 +#: src/components/profile/ProfileDumpsTab.tsx:60 +#: src/pages/Notifications.tsx:356 #: src/pages/UserDumps.tsx:94 #: src/pages/UserUpvoted.tsx:79 msgid "Nothing here yet." @@ -1019,8 +1015,8 @@ msgstr "Nothing here yet." #: src/components/layout/NotificationBell.tsx:54 #: src/components/profile/ProfileSettingsTab.tsx:108 -#: src/pages/Notifications.tsx:295 -#: src/pages/Notifications.tsx:432 +#: src/pages/Notifications.tsx:282 +#: src/pages/Notifications.tsx:334 msgid "Notifications" msgstr "Notifications" @@ -1109,22 +1105,22 @@ msgstr "Picked up where you left off." msgid "Play" msgstr "Play" -#: src/pages/PlaylistDetail.tsx:900 +#: src/components/playlist/PlaylistEditForm.tsx:157 msgid "Playlist title" msgstr "Playlist title" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:182 #: src/components/layout/UserMenu.tsx:78 -#: src/pages/Search.tsx:199 -#: src/pages/UserPlaylists.tsx:320 -#: src/pages/UserPublicProfile.tsx:198 +#: src/pages/Search.tsx:119 +#: src/pages/UserPlaylists.tsx:254 +#: src/pages/UserPublicProfile.tsx:190 msgid "Playlists" msgstr "Playlists" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/components/profile/ProfilePlaylistsTab.tsx:34 +#: src/components/profile/ProfilePlaylistsTab.tsx:35 msgid "Playlists ({0}{1})" msgstr "Playlists ({0}{1})" @@ -1149,9 +1145,9 @@ msgstr "Previous track" #: src/components/dump/DumpCard.tsx:148 #: src/components/dump/JournalCard.tsx:142 #: src/components/playlist/PlaylistCard.tsx:95 +#: src/components/playlist/PlaylistHeader.tsx:75 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/pages/Dump.tsx:497 -#: src/pages/PlaylistDetail.tsx:653 msgid "private" msgstr "private" @@ -1160,7 +1156,7 @@ msgid "Private" msgstr "Private" #: src/components/playlist/PlaylistCard.tsx:94 -#: src/pages/PlaylistDetail.tsx:652 +#: src/components/playlist/PlaylistHeader.tsx:75 msgid "public" msgstr "public" @@ -1202,7 +1198,7 @@ msgstr "Related" msgid "Remove file" msgstr "Remove file" -#: src/pages/PlaylistDetail.tsx:743 +#: src/components/playlist/PlaylistDumpList.tsx:108 msgid "Remove from playlist" msgstr "Remove from playlist" @@ -1263,27 +1259,27 @@ msgstr "Role" #: src/components/category/CategoryManager.tsx:216 #: src/components/chat/ChatEditBox.tsx:85 #: src/components/comment/CommentNode.tsx:214 -#: src/components/profile/DescriptionEditor.tsx:88 +#: src/components/playlist/PlaylistEditForm.tsx:212 +#: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 #: src/pages/Dump.tsx:424 #: src/pages/DumpEdit.tsx:545 -#: src/pages/PlaylistDetail.tsx:955 msgid "Save" msgstr "Save" #: src/components/comment/CommentNode.tsx:215 +#: src/components/playlist/PlaylistEditForm.tsx:208 #: src/components/profile/ChangePasswordModal.tsx:120 -#: src/components/profile/DescriptionEditor.tsx:85 +#: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 #: src/pages/Dump.tsx:423 -#: src/pages/PlaylistDetail.tsx:951 #: src/pages/ResetPassword.tsx:136 msgid "Saving…" msgstr "Saving…" #: src/components/layout/AppHeader.tsx:162 #: src/components/layout/SearchBar.tsx:82 -#: src/pages/Search.tsx:63 +#: src/pages/Search.tsx:65 msgid "Search" msgstr "Search" @@ -1299,11 +1295,11 @@ msgstr "Search emoji" msgid "Search emoji…" msgstr "Search emoji…" -#: src/pages/Search.tsx:240 +#: src/pages/Search.tsx:160 msgid "Search failed" msgstr "Search failed" -#: src/pages/Search.tsx:235 +#: src/pages/Search.tsx:155 msgid "Searching…" msgstr "Searching…" @@ -1333,7 +1329,7 @@ msgstr "Server unreachable" msgid "Set new password" msgstr "Set new password" -#: src/pages/UserPublicProfile.tsx:202 +#: src/pages/UserPublicProfile.tsx:194 msgid "Settings" msgstr "Settings" @@ -1355,8 +1351,8 @@ msgstr "Some images couldn't be uploaded." #: src/components/form/FormError.tsx:31 #: src/components/layout/PageError.tsx:29 -#: src/contexts/ws/WSProvider.tsx:726 -#: src/pages/PlaylistDetail.tsx:687 +#: src/contexts/ws/WSProvider.tsx:728 +#: src/pages/PlaylistDetail.tsx:119 msgid "Something went wrong" msgstr "Something went wrong" @@ -1380,15 +1376,11 @@ msgstr "Submit search" msgid "That file is over {limit}." msgstr "That file is over {limit}." -#: src/pages/Notifications.tsx:235 +#: src/pages/Notifications.tsx:222 msgid "the chat" msgstr "the chat" -#: src/pages/PlaylistDetail.tsx:513 -msgid "The dump couldn't be put back. Try again." -msgstr "The dump couldn't be put back. Try again." - -#: src/pages/PlaylistDetail.tsx:461 +#: src/hooks/playlist/usePlaylist.ts:160 msgid "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again." @@ -1396,6 +1388,10 @@ msgstr "The dump couldn't be removed. Try again." msgid "The image couldn't be uploaded." msgstr "The image couldn't be uploaded." +#: src/hooks/playlist/usePlaylist.ts:306 +msgid "The new order couldn't be saved. Try again." +msgstr "The new order couldn't be saved. Try again." + #: src/components/comment/CommentNode.tsx:280 msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." @@ -1430,7 +1426,7 @@ msgstr "Title" msgid "Title is required." msgstr "Title is required." -#: src/pages/Notifications.tsx:470 +#: src/pages/Notifications.tsx:372 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Today" @@ -1448,7 +1444,7 @@ msgstr "Try again" msgid "Type a message…" msgstr "Type a message…" -#: src/pages/PlaylistDetail.tsx:755 +#: src/components/playlist/PlaylistDumpList.tsx:120 msgid "Undo" msgstr "Undo" @@ -1495,7 +1491,7 @@ msgstr "Upvoted" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:51 +#: src/components/profile/ProfileDumpsTab.tsx:52 msgid "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})" @@ -1528,7 +1524,7 @@ msgstr "User menu" msgid "Username" msgstr "Username" -#: src/pages/Search.tsx:198 +#: src/pages/Search.tsx:118 msgid "Users" msgstr "Users" @@ -1549,7 +1545,7 @@ msgstr "Volume" msgid "What makes it worth it?" msgstr "What makes it worth it?" -#: src/components/profile/DescriptionEditor.tsx:77 +#: src/components/profile/DescriptionEditor.tsx:78 #: src/components/profile/ProfileDescription.tsx:79 msgid "Who am I?" msgstr "Who am I?" @@ -1571,7 +1567,7 @@ msgstr "Why?" msgid "Write a reply…" msgstr "Write a reply…" -#: src/pages/Notifications.tsx:472 +#: src/pages/Notifications.tsx:374 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Yesterday" @@ -1580,11 +1576,11 @@ msgstr "Yesterday" msgid "You can post it anyway." msgstr "You can post it anyway." -#: src/pages/Notifications.tsx:457 +#: src/pages/Notifications.tsx:359 msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." -#: src/contexts/ws/WSProvider.tsx:907 +#: src/contexts/ws/WSProvider.tsx:909 msgid "You're offline — not sent" msgstr "You're offline — not sent" diff --git a/src/locales/fr.js b/src/locales/fr.js index e51baa41..cddc8013 100644 --- a/src/locales/fr.js +++ b/src/locales/fr.js @@ -1,5 +1,5 @@ /*eslint-disable*/ module.exports = { messages: JSON.parse( - '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"8Ug9jB":["Connexe"],"8ZsakT":["Mot de passe"],"92o1-U":["Suppression échouée"],"9BruTc":["Pourquoi ?"],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"9xDZu_":["Impossible de charger."],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AEfu-D":["Dans les collections"],"AHZflp":["Tribune"],"AQbgNR":["Suivre ",["targetUsername"]],"AS7WoE":["Repartir de zéro"],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"AhNP40":["Position"],"AnRu_j":["Annuler la réponse"],"B4LeYP":["Échec de la suppression (",["0"],")"],"BjitBo":["Télécharger ",["0"]],"BlpJ2G":["Reprise de votre brouillon."],"CI50ct":["Voté"],"C_qQE4":["Déjà recommandé par ",["0"]," ",["1"]],"Cj24wt":["Inscription…"],"CnqayG":["La mise à jour a échoué. Réessayez."],"D8DRYV":["Supprimer ce message ?"],"DI3NBO":["Ouvrir sur ",["0"]],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"]," a voté pour <1>",["1"],""],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"Gv5nA5":["Format de réponse inattendu"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"]," a commenté sur <1>",["1"],""],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"P5MuiB":["Ajouter une image"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"QctOgc":["Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"V38P77":["Seuls son auteur et les modérateurs peuvent modifier ce dump."],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W5A0Ly":["Une erreur inattendue s\'est produite."],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"]," a aimé votre commentaire sur <1>",["1"],""],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"dqwCfR":["Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l\'aperçu plus tard."],"dznB-Y":["Seules les images peuvent être déposées ici."],"eFSqvc":["Impossible de publier la réponse"],"eIEHHV":["Changer la couleur de peau"],"eOfXq3":["Un titre est requis."],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"eloqXQ":["Vous pouvez la publier quand même."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"f3cEDO":["Changer l\'image"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hfk-mb":["Le dump n\'a pas pu être remis. Réessayez."],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pd3FCq":["Ce fichier dépasse ",["limit"],"."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"wtxFp4":["Fichier trop volumineux (max ",["max"],")."],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', + '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"]," a suivi votre collection <1>",["1"],""],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"8Ug9jB":["Connexe"],"8ZsakT":["Mot de passe"],"92o1-U":["Suppression échouée"],"9BruTc":["Pourquoi ?"],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AEfu-D":["Dans les collections"],"AHZflp":["Tribune"],"AQbgNR":["Suivre ",["targetUsername"]],"AS7WoE":["Repartir de zéro"],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"AhNP40":["Position"],"AnRu_j":["Annuler la réponse"],"B4LeYP":["Échec de la suppression (",["0"],")"],"BjitBo":["Télécharger ",["0"]],"BlpJ2G":["Reprise de votre brouillon."],"CI50ct":["Voté"],"C_qQE4":["Déjà recommandé par ",["0"]," ",["1"]],"Cj24wt":["Inscription…"],"CnqayG":["La mise à jour a échoué. Réessayez."],"D8DRYV":["Supprimer ce message ?"],"DI3NBO":["Ouvrir sur ",["0"]],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"]," a voté pour <1>",["1"],""],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"Gv5nA5":["Format de réponse inattendu"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"]," a commenté sur <1>",["1"],""],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"P5MuiB":["Ajouter une image"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"QctOgc":["Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"]," a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"]," a été ajouté à <1>",["1"],""],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"V38P77":["Seuls son auteur et les modérateurs peuvent modifier ce dump."],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W5A0Ly":["Une erreur inattendue s\'est produite."],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"]," a aimé votre commentaire sur <1>",["1"],""],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"dqwCfR":["Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l\'aperçu plus tard."],"dznB-Y":["Seules les images peuvent être déposées ici."],"eFSqvc":["Impossible de publier la réponse"],"eIEHHV":["Changer la couleur de peau"],"eOfXq3":["Un titre est requis."],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"eloqXQ":["Vous pouvez la publier quand même."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"f3cEDO":["Changer l\'image"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"]," a publié <1>",["1"],""],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"]," vous a mentionné dans <1>",["where"],""],"pSheLH":["Aucun invité pour le moment."],"pd3FCq":["Ce fichier dépasse ",["limit"],"."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qnUJBI":["Le nouvel ordre n\'a pas pu être enregistré. Réessayez."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter"],"wtxFp4":["Fichier trop volumineux (max ",["max"],")."],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}', ), }; diff --git a/src/locales/fr.po b/src/locales/fr.po index 8b8f85c6..df227388 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -55,7 +55,7 @@ msgstr "{0} de plus en ligne : {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" -#: src/pages/Search.tsx:200 +#: src/pages/Search.tsx:120 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -68,8 +68,8 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" #: src/components/dump/create/DumpCreateModal.tsx:741 -#: src/pages/PlaylistDetail.tsx:580 -#: src/pages/UserPublicProfile.tsx:167 +#: src/pages/PlaylistDetail.tsx:80 +#: src/pages/UserPublicProfile.tsx:159 msgid "← Back" msgstr "← Retour" @@ -80,7 +80,7 @@ msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" #: src/pages/UserDumps.tsx:74 -#: src/pages/UserPlaylists.tsx:306 +#: src/pages/UserPlaylists.tsx:240 #: src/pages/UserUpvoted.tsx:60 msgid "← Back to profile" msgstr "← Retour au profil" @@ -99,47 +99,47 @@ msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:212 +#: src/pages/Notifications.tsx:199 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} a commenté sur <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:172 +#: src/pages/Notifications.tsx:159 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} a suivi votre collection <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:222 +#: src/pages/Notifications.tsx:209 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} a aimé votre commentaire sur <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:238 +#: src/pages/Notifications.tsx:225 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} vous a mentionné dans <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:182 +#: src/pages/Notifications.tsx:169 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:163 +#: src/pages/Notifications.tsx:150 msgid "<0>{0} started following you" msgstr "<0>{0} a commencé à vous suivre" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:202 +#: src/pages/Notifications.tsx:189 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} a voté pour <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:192 +#: src/pages/Notifications.tsx:179 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" @@ -147,11 +147,11 @@ msgstr "<0>{0} a été ajouté à <1>{1}" msgid "1 new message" msgstr "1 nouveau message" -#: src/pages/Notifications.tsx:233 +#: src/pages/Notifications.tsx:220 msgid "a comment" msgstr "un commentaire" -#: src/pages/Notifications.tsx:236 +#: src/pages/Notifications.tsx:223 msgid "a post" msgstr "une publication" @@ -228,8 +228,8 @@ msgstr "Auto" msgid "Back to login" msgstr "Retour à la connexion" -#: src/contexts/ws/WSProvider.tsx:392 -#: src/contexts/ws/WSProvider.tsx:758 +#: src/contexts/ws/WSProvider.tsx:394 +#: src/contexts/ws/WSProvider.tsx:760 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." @@ -239,16 +239,16 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo #: src/components/comment/CommentForm.tsx:127 #: src/components/dump/create/DumpCreateModal.tsx:751 #: src/components/form/FormActions.tsx:42 -#: src/components/profile/DescriptionEditor.tsx:96 +#: src/components/playlist/PlaylistEditForm.tsx:205 +#: src/components/profile/DescriptionEditor.tsx:97 #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Dump.tsx:432 #: src/pages/DumpEdit.tsx:542 -#: src/pages/PlaylistDetail.tsx:948 msgid "Cancel" msgstr "Annuler" -#: src/pages/PlaylistDetail.tsx:753 +#: src/components/playlist/PlaylistDumpList.tsx:118 msgid "Cancel removal" msgstr "Annuler la suppression" @@ -341,7 +341,7 @@ msgstr "Thème de couleur" msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" -#: src/contexts/ws/WSProvider.tsx:751 +#: src/contexts/ws/WSProvider.tsx:753 msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" @@ -362,14 +362,10 @@ msgid "Could not connect to the server. Check your connection and try again." msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez." #: src/pages/DumpEdit.tsx:530 -#: src/pages/UserPlaylists.tsx:330 +#: src/pages/UserPlaylists.tsx:264 msgid "Could not delete" msgstr "Suppression impossible" -#: src/components/social/UserListPopover.tsx:167 -msgid "Could not load." -msgstr "Impossible de charger." - #: src/pages/DumpEdit.tsx:490 msgid "Could not save" msgstr "Sauvegarde impossible" @@ -396,7 +392,7 @@ msgstr "Créer et ajouter" #. placeholder {0}: created.items.length #. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:335 +#: src/pages/UserPlaylists.tsx:269 msgid "Created ({0}{1})" msgstr "Créées ({0}{1})" @@ -423,8 +419,8 @@ msgstr "Onglet par défaut" #: src/components/chat/ChatMessageItem.tsx:213 #: src/components/comment/CommentNode.tsx:263 #: src/components/comment/CommentNode.tsx:269 +#: src/components/playlist/PlaylistEditForm.tsx:201 #: src/components/shared/ConfirmModal.tsx:34 -#: src/pages/PlaylistDetail.tsx:944 msgid "Delete" msgstr "Supprimer" @@ -448,8 +444,8 @@ msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" #: src/components/playlist/PlaylistCard.tsx:132 -#: src/pages/PlaylistDetail.tsx:766 -#: src/pages/UserPlaylists.tsx:393 +#: src/pages/PlaylistDetail.tsx:136 +#: src/pages/UserPlaylists.tsx:327 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -465,8 +461,8 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/pages/PlaylistDetail.tsx:765 -#: src/pages/UserPlaylists.tsx:392 +#: src/pages/PlaylistDetail.tsx:135 +#: src/pages/UserPlaylists.tsx:326 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -475,7 +471,7 @@ msgid "deleted message" msgstr "message supprimé" #: src/components/playlist/PlaylistCreateForm.tsx:104 -#: src/pages/PlaylistDetail.tsx:916 +#: src/components/playlist/PlaylistEditForm.tsx:173 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -525,19 +521,19 @@ msgstr "Recommander" msgid "Dumped!" msgstr "Recommandé !" -#: src/pages/Search.tsx:196 +#: src/pages/Search.tsx:116 #: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:197 +#: src/pages/UserPublicProfile.tsx:189 msgid "Dumps" msgstr "Recos" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:36 +#: src/components/profile/ProfileDumpsTab.tsx:37 msgid "Dumps ({0}{1})" msgstr "Recos ({0}{1})" -#: src/pages/Notifications.tsx:473 +#: src/pages/Notifications.tsx:375 msgid "Earlier" msgstr "Plus tôt" @@ -545,8 +541,8 @@ msgstr "Plus tôt" #: src/components/chat/ChatMessageItem.tsx:104 #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:254 +#: src/components/playlist/PlaylistHeader.tsx:58 #: src/pages/Dump.tsx:550 -#: src/pages/PlaylistDetail.tsx:634 msgid "Edit" msgstr "Modifier" @@ -577,8 +573,8 @@ msgstr "modifié" #. placeholder {0}: relativeTime(message.updatedAt) #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 +#: src/components/playlist/PlaylistHeader.tsx:93 #: src/pages/Dump.tsx:491 -#: src/pages/PlaylistDetail.tsx:673 msgid "edited {0}" msgstr "modifié {0}" @@ -587,8 +583,8 @@ msgstr "modifié {0}" #. placeholder {0}: message.updatedAt.toLocaleString() #: src/components/chat/ChatMessageItem.tsx:124 #: src/components/comment/CommentNode.tsx:201 +#: src/components/playlist/PlaylistHeader.tsx:91 #: src/pages/Dump.tsx:489 -#: src/pages/PlaylistDetail.tsx:670 msgid "Edited {0}" msgstr "Modifié le {0}" @@ -605,7 +601,7 @@ msgstr "Édition de {0}" msgid "Email address" msgstr "Adresse e-mail" -#: src/pages/Search.tsx:229 +#: src/pages/Search.tsx:149 msgid "Enter a query to search." msgstr "Saisissez une recherche." @@ -632,7 +628,7 @@ msgid "Failed to generate invite" msgstr "Impossible de générer une invitation" #: src/components/shared/ListState.tsx:43 -#: src/pages/Notifications.tsx:447 +#: src/pages/Notifications.tsx:349 msgid "Failed to load" msgstr "Chargement échoué" @@ -648,9 +644,9 @@ msgstr "Impossible de publier le commentaire" msgid "Failed to post reply" msgstr "Impossible de publier la réponse" -#: src/components/profile/DescriptionEditor.tsx:98 +#: src/components/playlist/PlaylistEditForm.tsx:193 +#: src/components/profile/DescriptionEditor.tsx:99 #: src/components/profile/EmailEditor.tsx:86 -#: src/pages/PlaylistDetail.tsx:936 msgid "Failed to save" msgstr "Enregistrement échoué" @@ -695,23 +691,23 @@ msgstr "Suivre {targetUsername}" msgid "Follow playlist" msgstr "Suivre la collection" -#: src/components/feed/FollowedFeed.tsx:215 +#: src/components/feed/FollowedFeed.tsx:151 msgid "Follow some public playlists to see their dumps here." msgstr "Suivez des collections publiques pour voir leurs recos ici." -#: src/components/feed/FollowedFeed.tsx:207 +#: src/components/feed/FollowedFeed.tsx:143 msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." #: src/components/feed/FeedTabBar.tsx:48 #: src/components/profile/ProfileSettingsTab.tsx:44 -#: src/pages/UserPublicProfile.tsx:199 +#: src/pages/UserPublicProfile.tsx:191 msgid "Followed" msgstr "Suivi" #. placeholder {0}: followed.items.length #. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:366 +#: src/pages/UserPlaylists.tsx:300 msgid "Followed ({0}{1})" msgstr "Suivies ({0}{1})" @@ -729,11 +725,11 @@ msgstr "Abonné" msgid "Forgot password?" msgstr "Mot de passe oublié ?" -#: src/components/feed/FollowedFeed.tsx:196 +#: src/components/feed/FollowedFeed.tsx:132 msgid "From people" msgstr "De personnes" -#: src/components/feed/FollowedFeed.tsx:197 +#: src/components/feed/FollowedFeed.tsx:133 msgid "From playlists" msgstr "De collections" @@ -789,7 +785,7 @@ msgid "invited by" msgstr "invité par" #: src/components/profile/ProfileInviteesTab.tsx:76 -#: src/pages/UserPublicProfile.tsx:200 +#: src/pages/UserPublicProfile.tsx:192 msgid "Invitees" msgstr "Invités" @@ -818,7 +814,7 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" -#: src/contexts/ws/WSProvider.tsx:757 +#: src/contexts/ws/WSProvider.tsx:759 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" @@ -847,11 +843,11 @@ msgstr "Chargement des messages…" msgid "Loading more…" msgstr "Chargement…" -#: src/pages/PlaylistDetail.tsx:564 +#: src/pages/PlaylistDetail.tsx:64 msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:150 +#: src/pages/UserPublicProfile.tsx:142 msgid "Loading profile…" msgstr "Chargement du profil…" @@ -861,10 +857,10 @@ msgstr "Chargement du profil…" #: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/shared/ListState.tsx:38 #: src/components/shared/LoadMoreButton.tsx:32 -#: src/components/social/UserListPopover.tsx:224 -#: src/pages/Notifications.tsx:443 +#: src/components/social/UserListPopover.tsx:150 +#: src/pages/Notifications.tsx:345 #: src/pages/UserDumps.tsx:62 -#: src/pages/UserPlaylists.tsx:294 +#: src/pages/UserPlaylists.tsx:228 #: src/pages/UserUpvoted.tsx:48 msgid "Loading…" msgstr "Chargement…" @@ -885,7 +881,7 @@ msgid "Log in to vote" msgstr "Se connecter pour voter" #: src/components/profile/ProfileHeader.tsx:174 -#: src/pages/UserPublicProfile.tsx:171 +#: src/pages/UserPublicProfile.tsx:163 msgid "Log out" msgstr "Se déconnecter" @@ -897,7 +893,7 @@ msgstr "Connexion…" msgid "Login failed" msgstr "Connexion échouée" -#: src/pages/UserPublicProfile.tsx:205 +#: src/pages/UserPublicProfile.tsx:197 msgid "Manage" msgstr "Administration" @@ -918,7 +914,7 @@ msgstr "Couper le son" msgid "Name" msgstr "Nom" -#: src/pages/Notifications.tsx:436 +#: src/pages/Notifications.tsx:338 msgid "new" msgstr "nouveau" @@ -953,15 +949,15 @@ msgstr "Suivant →" msgid "Next track" msgstr "Piste suivante" -#: src/pages/PlaylistDetail.tsx:693 +#: src/components/playlist/PlaylistDumpList.tsx:46 msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." -#: src/pages/Search.tsx:246 +#: src/pages/Search.tsx:165 msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." -#: src/components/feed/DumpFeed.tsx:74 +#: src/components/feed/DumpFeed.tsx:76 #: src/components/feed/JournalFeed.tsx:36 msgid "No dumps yet. Be the first!" msgstr "Pas encore de recos. Soyez le premier !" @@ -971,7 +967,7 @@ msgid "No emoji found." msgstr "Aucun emoji trouvé." #: src/components/profile/ProfileFollowedTab.tsx:110 -#: src/pages/UserPlaylists.tsx:375 +#: src/pages/UserPlaylists.tsx:309 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." @@ -983,25 +979,25 @@ msgstr "Aucun invité pour le moment." msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" -#: src/components/social/UserListPopover.tsx:232 +#: src/components/social/UserListPopover.tsx:158 msgid "No one yet." msgstr "Personne pour le moment." -#: src/pages/Search.tsx:288 +#: src/pages/Search.tsx:206 msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:51 -#: src/pages/UserPlaylists.tsx:344 +#: src/components/profile/ProfilePlaylistsTab.tsx:52 +#: src/pages/UserPlaylists.tsx:278 msgid "No playlists yet." msgstr "Pas encore de collections." -#: src/contexts/ws/WSProvider.tsx:926 +#: src/contexts/ws/WSProvider.tsx:928 msgid "No reply from the server — this message may not have been sent" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" -#: src/pages/Search.tsx:262 +#: src/pages/Search.tsx:180 msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." @@ -1009,9 +1005,9 @@ msgstr "Aucun utilisateur ne correspond à « {q} »." msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." -#: src/components/profile/ProfileDumpsTab.tsx:45 -#: src/components/profile/ProfileDumpsTab.tsx:59 -#: src/pages/Notifications.tsx:454 +#: src/components/profile/ProfileDumpsTab.tsx:46 +#: src/components/profile/ProfileDumpsTab.tsx:60 +#: src/pages/Notifications.tsx:356 #: src/pages/UserDumps.tsx:94 #: src/pages/UserUpvoted.tsx:79 msgid "Nothing here yet." @@ -1019,8 +1015,8 @@ msgstr "Rien ici pour l'instant." #: src/components/layout/NotificationBell.tsx:54 #: src/components/profile/ProfileSettingsTab.tsx:108 -#: src/pages/Notifications.tsx:295 -#: src/pages/Notifications.tsx:432 +#: src/pages/Notifications.tsx:282 +#: src/pages/Notifications.tsx:334 msgid "Notifications" msgstr "Notifications" @@ -1109,22 +1105,22 @@ msgstr "Reprise de votre brouillon." msgid "Play" msgstr "Lecture" -#: src/pages/PlaylistDetail.tsx:900 +#: src/components/playlist/PlaylistEditForm.tsx:157 msgid "Playlist title" msgstr "Titre de la collection" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:182 #: src/components/layout/UserMenu.tsx:78 -#: src/pages/Search.tsx:199 -#: src/pages/UserPlaylists.tsx:320 -#: src/pages/UserPublicProfile.tsx:198 +#: src/pages/Search.tsx:119 +#: src/pages/UserPlaylists.tsx:254 +#: src/pages/UserPublicProfile.tsx:190 msgid "Playlists" msgstr "Collections" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/components/profile/ProfilePlaylistsTab.tsx:34 +#: src/components/profile/ProfilePlaylistsTab.tsx:35 msgid "Playlists ({0}{1})" msgstr "Collections ({0}{1})" @@ -1149,9 +1145,9 @@ msgstr "Piste précédente" #: src/components/dump/DumpCard.tsx:148 #: src/components/dump/JournalCard.tsx:142 #: src/components/playlist/PlaylistCard.tsx:95 +#: src/components/playlist/PlaylistHeader.tsx:75 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/pages/Dump.tsx:497 -#: src/pages/PlaylistDetail.tsx:653 msgid "private" msgstr "privé" @@ -1160,7 +1156,7 @@ msgid "Private" msgstr "Privé" #: src/components/playlist/PlaylistCard.tsx:94 -#: src/pages/PlaylistDetail.tsx:652 +#: src/components/playlist/PlaylistHeader.tsx:75 msgid "public" msgstr "public" @@ -1202,7 +1198,7 @@ msgstr "Connexe" msgid "Remove file" msgstr "Supprimer le fichier" -#: src/pages/PlaylistDetail.tsx:743 +#: src/components/playlist/PlaylistDumpList.tsx:108 msgid "Remove from playlist" msgstr "Retirer de la collection" @@ -1263,27 +1259,27 @@ msgstr "Rôle" #: src/components/category/CategoryManager.tsx:216 #: src/components/chat/ChatEditBox.tsx:85 #: src/components/comment/CommentNode.tsx:214 -#: src/components/profile/DescriptionEditor.tsx:88 +#: src/components/playlist/PlaylistEditForm.tsx:212 +#: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 #: src/pages/Dump.tsx:424 #: src/pages/DumpEdit.tsx:545 -#: src/pages/PlaylistDetail.tsx:955 msgid "Save" msgstr "Enregistrer" #: src/components/comment/CommentNode.tsx:215 +#: src/components/playlist/PlaylistEditForm.tsx:208 #: src/components/profile/ChangePasswordModal.tsx:120 -#: src/components/profile/DescriptionEditor.tsx:85 +#: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 #: src/pages/Dump.tsx:423 -#: src/pages/PlaylistDetail.tsx:951 #: src/pages/ResetPassword.tsx:136 msgid "Saving…" msgstr "Enregistrement…" #: src/components/layout/AppHeader.tsx:162 #: src/components/layout/SearchBar.tsx:82 -#: src/pages/Search.tsx:63 +#: src/pages/Search.tsx:65 msgid "Search" msgstr "Rechercher" @@ -1299,11 +1295,11 @@ msgstr "Rechercher un emoji" msgid "Search emoji…" msgstr "Rechercher un emoji…" -#: src/pages/Search.tsx:240 +#: src/pages/Search.tsx:160 msgid "Search failed" msgstr "Recherche échouée" -#: src/pages/Search.tsx:235 +#: src/pages/Search.tsx:155 msgid "Searching…" msgstr "Recherche…" @@ -1333,7 +1329,7 @@ msgstr "Serveur inaccessible" msgid "Set new password" msgstr "Définir un nouveau mot de passe" -#: src/pages/UserPublicProfile.tsx:202 +#: src/pages/UserPublicProfile.tsx:194 msgid "Settings" msgstr "Paramètres" @@ -1355,8 +1351,8 @@ msgstr "Certaines images n'ont pas pu être envoyées." #: src/components/form/FormError.tsx:31 #: src/components/layout/PageError.tsx:29 -#: src/contexts/ws/WSProvider.tsx:726 -#: src/pages/PlaylistDetail.tsx:687 +#: src/contexts/ws/WSProvider.tsx:728 +#: src/pages/PlaylistDetail.tsx:119 msgid "Something went wrong" msgstr "Une erreur est survenue" @@ -1380,15 +1376,11 @@ msgstr "Lancer la recherche" msgid "That file is over {limit}." msgstr "Ce fichier dépasse {limit}." -#: src/pages/Notifications.tsx:235 +#: src/pages/Notifications.tsx:222 msgid "the chat" msgstr "la tribune" -#: src/pages/PlaylistDetail.tsx:513 -msgid "The dump couldn't be put back. Try again." -msgstr "Le dump n'a pas pu être remis. Réessayez." - -#: src/pages/PlaylistDetail.tsx:461 +#: src/hooks/playlist/usePlaylist.ts:160 msgid "The dump couldn't be removed. Try again." msgstr "Le dump n'a pas pu être retiré. Réessayez." @@ -1396,6 +1388,10 @@ msgstr "Le dump n'a pas pu être retiré. Réessayez." msgid "The image couldn't be uploaded." msgstr "L'image n'a pas pu être envoyée." +#: src/hooks/playlist/usePlaylist.ts:306 +msgid "The new order couldn't be saved. Try again." +msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." + #: src/components/comment/CommentNode.tsx:280 msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." @@ -1430,7 +1426,7 @@ msgstr "Titre" msgid "Title is required." msgstr "Un titre est requis." -#: src/pages/Notifications.tsx:470 +#: src/pages/Notifications.tsx:372 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Aujourd'hui" @@ -1448,7 +1444,7 @@ msgstr "Réessayer" msgid "Type a message…" msgstr "Écrivez un message…" -#: src/pages/PlaylistDetail.tsx:755 +#: src/components/playlist/PlaylistDumpList.tsx:120 msgid "Undo" msgstr "Annuler" @@ -1495,7 +1491,7 @@ msgstr "Voté" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/components/profile/ProfileDumpsTab.tsx:51 +#: src/components/profile/ProfileDumpsTab.tsx:52 msgid "Upvoted ({0}{1})" msgstr "Votés ({0}{1})" @@ -1528,7 +1524,7 @@ msgstr "Menu utilisateur" msgid "Username" msgstr "Nom d'utilisateur" -#: src/pages/Search.tsx:198 +#: src/pages/Search.tsx:118 msgid "Users" msgstr "Utilisateurs" @@ -1549,7 +1545,7 @@ msgstr "Volume" msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" -#: src/components/profile/DescriptionEditor.tsx:77 +#: src/components/profile/DescriptionEditor.tsx:78 #: src/components/profile/ProfileDescription.tsx:79 msgid "Who am I?" msgstr "Qui suis-je ?" @@ -1571,7 +1567,7 @@ msgstr "Pourquoi ?" msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/pages/Notifications.tsx:472 +#: src/pages/Notifications.tsx:374 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Hier" @@ -1580,11 +1576,11 @@ msgstr "Hier" msgid "You can post it anyway." msgstr "Vous pouvez la publier quand même." -#: src/pages/Notifications.tsx:457 +#: src/pages/Notifications.tsx:359 msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." -#: src/contexts/ws/WSProvider.tsx:907 +#: src/contexts/ws/WSProvider.tsx:909 msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 8e737a2a..c510033d 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -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, ); diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index c1cfd000..73d7d729 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -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(); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b3a9a8c9..d01fad53 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -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( `feed:index:${categorySlug ?? "all"}`, hydrateDump, ); - const [dumpsState, setDumpsState] = useState(() => - 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> - >(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>>(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>>(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], ); diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index a9af4f3d..59c12310 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -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({ - 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>>( - 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([]); 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(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>>(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 ( @@ -447,7 +349,7 @@ export function Notifications(): React.JSX.Element { )} - {state.status === "loaded" && state.items.length === 0 && ( + {state.status === "loaded" && items.length === 0 && (
      🔕

      @@ -462,8 +364,8 @@ export function Notifications(): React.JSX.Element {

      )} - {state.status === "loaded" && state.items.length > 0 && - groupByDate(state.items).map(({ label, items }) => ( + {state.status === "loaded" && items.length > 0 && + groupByDate(items).map(({ label, items }) => (

      {label === "Today" @@ -508,8 +410,11 @@ export function Notifications(): React.JSX.Element {

      ))} - {state.status === "loaded" && state.hasMore && ( - + {state.status === "loaded" && state.data.hasMore && ( + )}
      diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 8d3019c7..cd9926e5 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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({ 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>(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(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 | null>( - null, - ); - - // A ref, since dragover fires faster than state renders. - const dragSrcRef = useRef(null); - const [dragOverIndex, setDragOverIndex] = useState(null); + state, + fading, + actionError, + applyDetails, + remove, + undoRemove, + move, + saveOrder, + deletePlaylist, + } = usePlaylist(playlistId); const [editOpen, setEditOpen] = useState(false); const headerRef = useHeightTransition(editOpen); const [confirmDelete, setConfirmDelete] = useState(false); - // A delete or a removal the server refused. A refused reorder just reloads. - const [actionError, setActionError] = useState(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>(new Set()); - // The removals still in flight, by dump, which an undo must wait for. - const removalsRef = useRef>>(new Map()); - // The server's last order, where such a dump comes back. - const dumpOrderRef = useRef([]); - - useLayoutEffect(() => { - activeDumpIdsRef.current = activeDumpIds; - }); - - useEffect(() => () => { - if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current); - }, []); - - const fetchAbortRef = useRef(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, - ); - 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); - 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 ( @@ -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 && ( - - )} -
      -
      -

      {playlist.title}

      - {!isOwner && ( - - )} - {canEdit && ( - - )} -
      - - {playlist.description && ( - - {playlist.description} - - )} - -
      - - {playlist.isPublic - ? public - : private} - - {playlist.ownerUsername && ( - - @{playlist.ownerUsername} - - )} - - - - {playlist.updatedAt && ( - - - - edited {relativeTime(playlist.updatedAt)} - - - - )} -
      -
      - + setEditOpen(true) : undefined} + /> )} @@ -687,277 +119,28 @@ export function PlaylistDetail(): React.JSX.Element { )} - {visibleDumps.length === 0 - ? ( -

      - No dumps in this playlist yet. -

      - ) - : ( -
      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; + - return ( -
      handleDragStart(activeIndex) - : undefined} - onDragOver={canEdit && isActive - ? (e) => handleDragOver(e, activeIndex) - : undefined} - onDragEnd={canEdit ? handleDragEnd : undefined} - > - {canEdit && isActive && ( - ⠿ - )} - - {canEdit && (isActive - ? ( - - ) - : phase === "cooldown" && ( - - ))} -
      - ); - })} -
      - )} {confirmDelete && ( { + setConfirmDelete(false); + void deletePlaylist(); + }} onCancel={() => setConfirmDelete(false)} /> )}
      ); } - -/** 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(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({ - 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>( - 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>( - await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, { - method: "POST", - body: fd, - }), - ), - )); - return; - } - - onSaved(updated); - }, - ); - - return ( - - { - /* `display: contents` keeps the image-beside-content flex layout while - still giving us a real
      for submit + Enter handling. */ - } - - ( - { - field.onChange(file); - setImagePreview(URL.createObjectURL(file)); - }} - /> - )} - /> - -
      - ( - { - if (e.key === "Escape") onCancel(); - }} - autoFocus - placeholder={t`Playlist title`} - maxLength={VALIDATION.PLAYLIST_TITLE_MAX} - /> - )} - /> - - ( - void onSubmit()} - onCancel={onCancel} - placeholder={t`Description (optional)`} - autoResize - rows={2} - maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX} - /> - )} - /> - - ( - - )} - /> - - - -
      - -
      - - Saving…} - disabled={!title.trim() || - description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX} - > - Save - -
      -
      -
      - -
      - ); -} diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index a6cc3a45..a735ec0a 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -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; + /** 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(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>( + await authFetch(pageUrl(url.toString(), page), { signal }), + ); + }, [authFetch, q]); - const [state, setState] = useState({ status: "idle" }); - const abortRef = useRef(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[]; total: number; hasMore: boolean }; - users: Wire[]; - playlists: Wire[]; - }; - }; - - 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( + 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) => PagedList) => + 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() && (

      Enter a query to search.

      )} - {state.status === "loading" && ( + {q.trim() && state.status === "loading" && (

      Searching…

      @@ -240,29 +160,27 @@ export function Search(): React.JSX.Element { )} - {state.status === "loaded" && tab === "dumps" && ( - <> - {state.dumps.items.length === 0 - ?

      {t`No dumps match "${q}".`}

      - : ( - <> - - - - )} - + {results && tab === "dumps" && ( + results.dumps.items.length === 0 + ?

      {t`No dumps match "${q}".`}

      + : ( + <> + + + + ) )} - {state.status === "loaded" && tab === "users" && ( - state.users.length === 0 + {results && tab === "users" && ( + results.users.length === 0 ?

      {t`No users match "${q}".`}

      : (
        - {state.users.map((u) => ( + {results.users.map((u) => (
      • {t`No playlists match "${q}".`}

        ) - : + : )} diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index 68f102c6..c1eadaf1 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -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 ( diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index 7087e391..abae0285 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -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) => PagedList, + ) => void; + const setCreatedList = useCallback( + (fn) => + setState((s) => + s.status !== "loaded" ? s : { ...s, created: fn(s.created) } + ), + [], + ); + const setFollowedList = useCallback( + (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>>(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>>(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]), ); diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index c85a4af8..1ff80c12 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -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(availableTabs, "dumps"); const loaded = state.status === "loaded"; + const followedUsersUrl = `${API_URL}/api/users/${username}/followed-users`; const followed = useFetchState( 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( username, @@ -115,33 +119,21 @@ export function UserPublicProfile(): React.JSX.Element { fetch(`${API_URL}/api/users/${username}/invitees`, { signal }) .then((res) => dataOr[]>(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>>) - .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) => PagedList) => + setFollowed((d) => ({ ...d, users: fn(d.users) })), + [setFollowed], + ), + (page) => + fetch(pageUrl(followedUsersUrl, page)) + .then((res) => readPage(res, deserializePublicUser)), + ); if (state.status === "loading") { return ( diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 7b6560ee..d6cfc5b7 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -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 ( diff --git a/src/test/components/social/UserListPopover.test.tsx b/src/test/components/social/UserListPopover.test.tsx index c45eb2b3..c63af304 100644 --- a/src/test/components/social/UserListPopover.test.tsx +++ b/src/test/components/social/UserListPopover.test.tsx @@ -53,7 +53,7 @@ describe("UserListPopover", () => { first.unmount(); route("GET /api/x/voters", () => new TypeError("offline")); renderWithContexts(); - await screen.findByText("Could not load."); + await screen.findByText(/Could not connect to the server/); }); test("a failed next page lets you try again", async () => { diff --git a/src/test/hooks/dump/useUserDumpFeed.test.tsx b/src/test/hooks/dump/useUserDumpFeed.test.tsx index 41a5df5f..16684376 100644 --- a/src/test/hooks/dump/useUserDumpFeed.test.tsx +++ b/src/test/hooks/dump/useUserDumpFeed.test.tsx @@ -46,8 +46,8 @@ describe("useUserDumpFeed", () => { { status: "loaded" } >; expect(s.profileUser.username).toBe("ann"); - expect(s.items[0].createdAt).toBeInstanceOf(Date); - expect(s.hasMore).toBe(true); + expect(s.dumps.items[0].createdAt).toBeInstanceOf(Date); + expect(s.dumps.hasMore).toBe(true); const [req] = calls("GET /api/users/:u/dumps"); expect(req.url.searchParams.get("page")).toBe("1"); expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`); @@ -91,7 +91,7 @@ describe("useUserDumpFeed", () => { act(() => obs.report(true)); await waitFor(() => { const s = result.current.state; - expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([ + expect(s.status === "loaded" && s.dumps.items.map((d) => d.id)).toEqual([ "a", "b", ]); @@ -100,8 +100,8 @@ describe("useUserDumpFeed", () => { typeof result.current.state, { status: "loaded" } >; - expect(s.hasMore).toBe(false); - expect(s.page).toBe(2); + expect(s.dumps.hasMore).toBe(false); + expect(s.dumps.page).toBe(2); }); test("changing user resets to loading", async () => { @@ -130,6 +130,6 @@ describe("useUserDumpFeed", () => { typeof result.current.state, { status: "loaded" } >; - expect(s.items.map((d) => d.id)).toEqual(["b"]); + expect(s.dumps.items.map((d) => d.id)).toEqual(["b"]); }); }); diff --git a/src/test/hooks/feed/useFeedCache.test.tsx b/src/test/hooks/feed/useFeedCache.test.tsx index ada7f3f2..2c2a2cc6 100644 --- a/src/test/hooks/feed/useFeedCache.test.tsx +++ b/src/test/hooks/feed/useFeedCache.test.tsx @@ -22,7 +22,13 @@ describe("useFeedCache", () => { const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), { wrapper: wrapper(), }); - act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300)); + act(() => + first.result.current.saveState({ + items: [{ n: 1 }], + page: 2, + hasMore: true, + }, 300) + ); const second = renderHook( () => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })), { wrapper: wrapper() }, @@ -40,7 +46,9 @@ describe("useFeedCache", () => { const first = renderHook(() => useFeedCache("k", (x) => x), { wrapper: wrapper(), }); - act(() => first.result.current.saveState([], 1, false, 0)); + act(() => + first.result.current.saveState({ items: [], page: 1, hasMore: false }, 0) + ); vi.advanceTimersByTime(11 * 60 * 1000); const second = renderHook(() => useFeedCache("k", (x) => x), { wrapper: wrapper(), @@ -92,6 +100,8 @@ describe("useFeedCache", () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("QuotaExceededError"); }); - expect(() => result.current.saveState([], 1, false, 0)).not.toThrow(); + expect(() => + result.current.saveState({ items: [], page: 1, hasMore: false }, 0) + ).not.toThrow(); }); }); diff --git a/src/test/hooks/feed/usePagedList.test.tsx b/src/test/hooks/feed/usePagedList.test.tsx new file mode 100644 index 00000000..e0703e16 --- /dev/null +++ b/src/test/hooks/feed/usePagedList.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { usePagedList } from "../../../hooks/feed/usePagedList.ts"; +import { deferred } from "../../fetch.ts"; + +describe("usePagedList", () => { + test("loads the first page, then appends the next ones", async () => { + const fetchPage = vi.fn((page: number) => + Promise.resolve({ items: [page], hasMore: page < 2 }) + ); + const { result } = renderHook(() => usePagedList("k", fetchPage)); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + act(() => result.current.loadMore()); + await waitFor(() => + expect(result.current.state).toEqual({ + status: "loaded", + data: { items: [1, 2], hasMore: false, page: 2, loadingMore: false }, + }) + ); + act(() => result.current.loadMore()); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test("loads one next page at a time, and a failed one can be tried again", async () => { + const next = deferred<{ items: number[]; hasMore: boolean }>(); + const fetchPage = vi.fn((page: number) => + page === 1 ? Promise.resolve({ items: [1], hasMore: true }) : next.promise + ); + const { result } = renderHook(() => usePagedList("k", fetchPage)); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + expect(fetchPage).toHaveBeenCalledTimes(2); + await act(async () => { + next.reject(new Error("offline")); + await next.promise.catch(() => {}); + }); + expect(result.current.state).toMatchObject({ + data: { items: [1], page: 1, loadingMore: false }, + }); + }); + + test("a cached list shows as it was, and carries on from its page", async () => { + const fetchPage = vi.fn((page: number) => + Promise.resolve({ items: [page], hasMore: false }) + ); + const { result } = renderHook(() => + usePagedList("k", fetchPage, { + items: [1, 2], + page: 2, + hasMore: true, + scrollY: 0, + }) + ); + expect(result.current.state).toMatchObject({ data: { items: [1, 2] } }); + act(() => result.current.loadMore()); + await waitFor(() => + expect(result.current.state).toMatchObject({ + data: { items: [1, 2, 3] }, + }) + ); + expect(fetchPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/hooks/shared/useFetchState.test.tsx b/src/test/hooks/shared/useFetchState.test.tsx index 0ae348ae..1cb34136 100644 --- a/src/test/hooks/shared/useFetchState.test.tsx +++ b/src/test/hooks/shared/useFetchState.test.tsx @@ -7,7 +7,7 @@ describe("useFetchState", () => { test("loads once enabled, and only once per key", async () => { const load = vi.fn(() => Promise.resolve(1)); const { result, rerender } = renderHook( - ({ enabled }) => useFetchState("a", load, enabled), + ({ enabled }) => useFetchState("a", load, { enabled }), { initialProps: { enabled: false } }, ); expect(load).not.toHaveBeenCalled(); @@ -25,7 +25,7 @@ describe("useFetchState", () => { test("turning it off doesn't stop a load under way", async () => { const d = deferred(); const { result, rerender } = renderHook( - ({ enabled }) => useFetchState("a", () => d.promise, enabled), + ({ enabled }) => useFetchState("a", () => d.promise, { enabled }), { initialProps: { enabled: true } }, ); rerender({ enabled: false }); @@ -79,4 +79,18 @@ describe("useFetchState", () => { act(() => result.current.setData((xs) => [...xs, 2])); expect(result.current.state).toEqual({ status: "loaded", data: [1, 2] }); }); + + test("an initial value starts loaded and loads nothing, for its key only", async () => { + const load = vi.fn(() => Promise.resolve(2)); + const { result, rerender } = renderHook( + ({ key, initial }) => useFetchState(key, load, { initial }), + { initialProps: { key: "a", initial: 1 as number | undefined } }, + ); + expect(result.current.state).toEqual({ status: "loaded", data: 1 }); + expect(load).not.toHaveBeenCalled(); + rerender({ key: "b", initial: undefined }); + await waitFor(() => + expect(result.current.state).toEqual({ status: "loaded", data: 2 }) + ); + }); }); diff --git a/src/test/pages/PlaylistDetail.test.tsx b/src/test/pages/PlaylistDetail.test.tsx index f154e08d..a2024b2a 100644 --- a/src/test/pages/PlaylistDetail.test.tsx +++ b/src/test/pages/PlaylistDetail.test.tsx @@ -76,7 +76,7 @@ describe("PlaylistDetail", () => { }); test("not found", async () => { - route("GET /api/playlists/:id", () => fail("nope", 404)); + route("GET /api/playlists/:id", () => fail("Playlist not found", 404)); renderApp(, { route: "/playlists/x", path: "/playlists/:playlistId", @@ -212,15 +212,13 @@ describe("PlaylistDetail", () => { expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument(); }); - test("removing a dump fades it out, with an undo", async () => { + test("a removal is sent once its fade ends, and an undo sends nothing", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { route( "DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }), ); - route("POST /api/playlists/:pid/dumps/:did", () => ({})); - route("PUT /api/playlists/:pid/order", () => ({})); const { user } = open(); await screen.findByText("Bravo"); const bravo = screen.getByText("Bravo").closest( @@ -229,33 +227,30 @@ describe("PlaylistDetail", () => { await user.click( within(bravo).getByRole("button", { name: "Remove from playlist" }), ); - expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe( - "/api/playlists/mix/dumps/b", - ); await user.click( within(bravo).getByRole("button", { name: "Cancel removal" }), ); - await waitFor(() => - expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1) - ); - expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ - dumpIds: ["a", "b", "c"], - }); + act(() => vi.advanceTimersByTime(2_400)); + expect(screen.getByText("Bravo")).toBeInTheDocument(); + expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(0); await user.click( within(bravo).getByRole("button", { name: "Remove from playlist" }), ); act(() => vi.advanceTimersByTime(2_400)); expect(screen.queryByText("Bravo")).not.toBeInTheDocument(); + expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe( + "/api/playlists/p1/dumps/b", + ); } finally { vi.useRealTimers(); } }); - test("a refused removal puts the dump back and says so", async () => { + test("leaving the page during the fade still sends the removal", async () => { route( "DELETE /api/playlists/:pid/dumps/:did", - () => fail("Forbidden", 403), + () => new Response(null, { status: 204 }), ); const { user } = open(); await screen.findByText("Bravo"); @@ -265,14 +260,36 @@ describe("PlaylistDetail", () => { await user.click( within(bravo).getByRole("button", { name: "Remove from playlist" }), ); - expect(await screen.findByRole("alert")).toHaveTextContent( - "couldn't be removed", - ); - expect(within(bravo).getByRole("button", { name: "Remove from playlist" })) - .toBeInTheDocument(); + act(() => globalThis.dispatchEvent(new Event("pagehide"))); + expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(1); }); - test("dragging reorders, and a refused reorder reloads the real order", async () => { + test("a refused removal puts the dump back and says so", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + route( + "DELETE /api/playlists/:pid/dumps/:did", + () => fail("Forbidden", 403), + ); + const { user } = open(); + await screen.findByText("Bravo"); + const bravo = screen.getByText("Bravo").closest( + ".playlist-dump-item", + ) as HTMLElement; + await user.click( + within(bravo).getByRole("button", { name: "Remove from playlist" }), + ); + act(() => vi.advanceTimersByTime(2_400)); + expect(await screen.findByRole("alert")).toHaveTextContent( + "couldn't be removed", + ); + expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]); + } finally { + vi.useRealTimers(); + } + }); + + test("dragging reorders, and a refused reorder goes back to the server's order", async () => { route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403)); open(); await screen.findByText("Charlie"); @@ -292,12 +309,23 @@ describe("PlaylistDetail", () => { expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"], }); - await waitFor(() => - expect(calls("GET /api/playlists/:id")).toHaveLength(2) - ); - await waitFor(() => - expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]) + expect(await screen.findByRole("alert")).toHaveTextContent( + "order couldn't be saved", ); + expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]); + }); + + test("a dump that goes private leaves, and comes back at its place", async () => { + const { socket } = open({ session: visitor }); + await screen.findByText("Bravo"); + socket.receive({ type: "dump_deleted", dumpId: "b" }); + expect(titles()).toEqual(["Alpha", "Charlie"]); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: "b", title: "Bravo" })), + }); + expect(await screen.findByText("Bravo")).toBeInTheDocument(); + expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]); }); test("deleting the playlist asks, then goes home", async () => { diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index 4f1b2f43..ecd3f5ec 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -9,6 +9,7 @@ import { makeDump, makePlaylist, makeSession, + makeToken, makeUser, page, wire, @@ -316,9 +317,10 @@ describe("UserPublicProfile", () => { }); test("change password keeps you signed in with the fresh tokens", async () => { + const freshToken = makeToken({ sub: "ann" }); route( "POST /api/users/me/change-password", - () => ({ token: "fresh-token", mediaToken: "fresh-media" }), + () => ({ token: freshToken, mediaToken: "fresh-media" }), ); const { user } = open({ session: annSession, @@ -353,7 +355,7 @@ describe("UserPublicProfile", () => { expect(await within(dialog).findByText("Password changed successfully.")) .toBeInTheDocument(); expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe( - "fresh-token", + freshToken, ); expect(calls("POST /api/users/me/change-password")[0].body).toEqual({ currentPassword: "oldpassword", diff --git a/src/test/utils/feed/pagedList.test.ts b/src/test/utils/feed/pagedList.test.ts index 92efea08..057fc363 100644 --- a/src/test/utils/feed/pagedList.test.ts +++ b/src/test/utils/feed/pagedList.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "vitest"; import { API_URL } from "../../../config/api.ts"; import { - fetchFirstPage, firstPage, + pageUrl, + readFirstPage, + readPage, restoredList, withFirst, } from "../../../utils/feed/pagedList.ts"; @@ -10,10 +12,15 @@ import { calls, fail, route } from "../../fetch.ts"; import { page } from "../../fixtures.ts"; describe("pagedList", () => { - test("fetchFirstPage asks for page 1 and deserializes it", async () => { + test("pageUrl adds the page to a query, or starts one", () => { + expect(pageUrl("/a", 2, 5)).toBe("/a?page=2&limit=5"); + expect(pageUrl("/a?c=x", 1, 5)).toBe("/a?c=x&page=1&limit=5"); + }); + + test("readFirstPage deserializes the first page", async () => { route("GET /api/things", () => page([{ id: "a" }], true)); - const list = await fetchFirstPage( - `${API_URL}/api/things`, + const list = await readFirstPage( + await fetch(pageUrl(`${API_URL}/api/things`, 1)), (w: { id: string }) => ({ ...w, seen: true }), ); expect(list).toEqual({ @@ -25,13 +32,13 @@ describe("pagedList", () => { expect(calls("GET /api/things")[0].url.searchParams.get("page")).toBe("1"); }); - test("fetchFirstPage makes a refused list empty, but not a lost one", async () => { + test("a refused page is an empty first page, and an error elsewhere", async () => { route("GET /api/refused", () => fail("No", 403)); - expect(await fetchFirstPage(`${API_URL}/api/refused`, (w) => w)) - .toEqual(firstPage([], false)); - route("GET /api/lost", () => new TypeError("Failed to fetch")); - await expect(fetchFirstPage(`${API_URL}/api/lost`, (w) => w)).rejects - .toThrow(TypeError); + expect( + await readFirstPage(await fetch(`${API_URL}/api/refused`), (w) => w), + ).toEqual(firstPage([], false)); + await expect(readPage(await fetch(`${API_URL}/api/refused`), (w) => w)) + .rejects.toThrow("No"); }); test("restoredList carries on from the cached page", () => { diff --git a/src/utils/feed/pagedList.ts b/src/utils/feed/pagedList.ts index d222b9dd..299e080c 100644 --- a/src/utils/feed/pagedList.ts +++ b/src/utils/feed/pagedList.ts @@ -1,22 +1,24 @@ /** - * A list of which the first pages are loaded, as the pages holding several - * such lists keep each one. + * Declares a list the API serves a page at a time, and reads its pages. Every + * such list in the app is a {@link PagedList}, whose next page `useLoadMore` + * appends. * * @module */ import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import type { PaginatedData, Wire } from "../../../shared/api.ts"; -import { dataOr } from "../shared/api.ts"; +import { dataOr, expectOk } from "../shared/api.ts"; /** - * Held for each of its lists by `UserPlaylists` and the profile, whose lists - * load together with the user and then a page at a time. + * Held for a list by `usePagedList`, or by a page that loads several lists + * with their user, as `UserPlaylists` and the profile do. `useLoadMore` loads + * the pages after `page`. */ export interface PagedList { items: T[]; hasMore: boolean; - /** The last page loaded, from which loading more carries on. */ + /** Loading more carries on from this page, the last loaded. */ page: number; loadingMore: boolean; } @@ -33,23 +35,63 @@ export function restoredList( return { items, hasMore, page, loadingMore: false }; } +/** + * Checks whether `list` has a next page to load now, which `useLoadMore` + * requires and `useInfiniteScroll` takes as `enabled`. A list not loaded yet + * is null. + */ +export function canLoadMore(list: PagedList | null): boolean { + return !!list && list.hasMore && !list.loadingMore; +} + +/** + * Returns a setter of a list's items as a setter of the list, for the sync + * hooks, which edit items only. + */ +export function mapItems( + fn: (items: T[]) => T[], +): (list: PagedList) => PagedList { + return (list) => ({ ...list, items: fn(list.items) }); +} + +/** Returns `url` asking for page `page`, `limit` items long. */ +export function pageUrl( + url: string, + page: number, + limit = DEFAULT_PAGE_SIZE, +): string { + return `${url}${url.includes("?") ? "&" : "?"}page=${page}&limit=${limit}`; +} + +/** + * Returns the page `res` answers, its items deserialized. + * + * @throws {Error} With the server's message when it refuses, and when the + * body isn't an envelope. + */ +export async function readPage( + res: Response, + deserialize: (wire: Wire) => T, +): Promise> { + const { items, hasMore } = await expectOk>>(res); + return { items: items.map(deserialize), hasMore }; +} + const EMPTY_PAGE: PaginatedData = { items: [], hasMore: false }; /** - * Returns the first page of the list at `url`, which takes `page` and `limit` - * in its query, or an empty list when the server refuses it. The profile pages - * load their lists with it beside the user, whose failure alone fails the - * page. + * Returns the first page `res` answers as a list, or an empty list when the + * server refuses it. The profile pages read their lists with it beside the + * user, whose failure alone fails the page. * - * @throws {Error} Offline, when aborted, and when the body isn't an envelope. + * @throws {Error} When the body isn't an envelope. */ -export async function fetchFirstPage( - url: string, +export async function readFirstPage( + res: Response, deserialize: (wire: Wire) => T, - init?: RequestInit, ): Promise> { const { items, hasMore } = await dataOr>>( - await fetch(`${url}?page=1&limit=${DEFAULT_PAGE_SIZE}`, init), + res, EMPTY_PAGE, ); return firstPage(items.map(deserialize), hasMore); diff --git a/src/utils/shared/api.ts b/src/utils/shared/api.ts index 6587ae07..e897145c 100644 --- a/src/utils/shared/api.ts +++ b/src/utils/shared/api.ts @@ -49,9 +49,9 @@ export async function expectOk(res: Response): Promise { /** * Returns the payload of `res`, or `fallback` when the response says - * `success: false`. The profile pages read their side lists with it, as - * `{ items: [], hasMore: false }` when refused, so that a list the server - * refuses shows empty rather than failing the page. + * `success: false`. `readFirstPage` reads the profile pages' lists with it, and + * the profile its invitees, so that a list the server refuses shows empty + * rather than failing the page. * * @throws {Error} When the body isn't JSON or isn't an envelope. */ diff --git a/src/utils/shared/loadState.ts b/src/utils/shared/loadState.ts index 79fd9bae..e9c4f169 100644 --- a/src/utils/shared/loadState.ts +++ b/src/utils/shared/loadState.ts @@ -1,6 +1,6 @@ /** - * The state of something a component loads from the API, which `ListState` - * renders while it isn't loaded. + * Declares the state of something a component loads from the API, which + * `ListState` renders while it isn't loaded. * * @module */ diff --git a/src/utils/shared/visited.ts b/src/utils/shared/visited.ts index 6a87f292..6278057a 100644 --- a/src/utils/shared/visited.ts +++ b/src/utils/shared/visited.ts @@ -7,7 +7,7 @@ const DUMP_KEY = "visited_dumps"; const PLAYLIST_KEY = "visited_playlists"; -/** The age past which an item shows no unread dot, visited or not. */ +// Past this age, an item shows no unread dot, visited or not. const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // Each key's visits, id to epoch ms, with the stored string they were parsed @@ -23,7 +23,7 @@ function load(key: string): Map { try { raw = localStorage.getItem(key); } catch { - // Blocked storage: whatever this page recorded. + // With storage blocked, only what this page recorded counts. return cache.get(key)?.visits ?? new Map(); } const cached = cache.get(key); @@ -36,7 +36,7 @@ function load(key: string): Map { ? new Map(parsed.map((id) => [id, Date.now()])) : new Map(Object.entries(parsed)); } catch { - // Unreadable: nothing visited. + // An unreadable list counts as nothing visited. } cache.set(key, { raw, visits }); return visits;