From 07ac4bc629dc449cb27278ef4d7dccb6d0c49227 Mon Sep 17 00:00:00 2001
From: khannurien
Date: Tue, 29 Sep 2026 21:19:33 +0000
Subject: [PATCH] =?UTF-8?q?v3:=20every=20paged=20list=20loads=20its=20page?=
=?UTF-8?q?s=20one=20way,=20PlaylistDetail=20is=20split=20like=20the=20pro?=
=?UTF-8?q?file,=20and=20the=20comments=20keep=20the=20README's=20rules=20?=
=?UTF-8?q?=E2=80=94=20Index,=20FollowedFeed,=20useUserDumpFeed,=20UserPla?=
=?UTF-8?q?ylists,=20UserListPopover,=20Notifications,=20Search=20and=20th?=
=?UTF-8?q?e=20profile's=20followed=20users=20each=20hand-wrote=20"fetch?=
=?UTF-8?q?=20the=20next=20page,=20append=20it,=20clear=20loadingMore=20on?=
=?UTF-8?q?=20failure".=20A=20list=20the=20API=20pages=20is=20now=20a=20Pa?=
=?UTF-8?q?gedList=20whose=20next=20page=20useLoadMore=20appends,=20and=20?=
=?UTF-8?q?a=20list=20that=20loads=20on=20its=20own=20goes=20through=20use?=
=?UTF-8?q?PagedList,=20which=20is=20useFetchState=20(now=20taking=20an=20?=
=?UTF-8?q?`initial`=20value,=20as=20a=20restored=20cache,=20that=20starts?=
=?UTF-8?q?=20loaded)=20plus=20useLoadMore.=20Pages=20are=20read=20by=20pa?=
=?UTF-8?q?geUrl=20with=20readPage=20(a=20refusal=20throws)=20or=20readFir?=
=?UTF-8?q?stPage=20(a=20refusal=20is=20empty,=20as=20fetchFirstPage=20was?=
=?UTF-8?q?),=20signed-in=20reads=20go=20through=20authFetch,=20and=20useF?=
=?UTF-8?q?eedCache's=20saveState=20takes=20the=20list.=20Notifications=20?=
=?UTF-8?q?keeps=20the=20socket's=20arrivals=20as=20their=20own=20list=20m?=
=?UTF-8?q?erged=20at=20render,=20as=20Index=20does=20with=20recentDumps,?=
=?UTF-8?q?=20and=20marks=20them=20read=20in=20one=20effect.=20PlaylistDet?=
=?UTF-8?q?ail=20(963=20lines)=20only=20puts=20the=20pieces=20together=20(?=
=?UTF-8?q?146):=20usePlaylist=20holds=20the=20playlist,=20its=20live=20sy?=
=?UTF-8?q?nc=20and=20its=20edits,=20PlaylistHeader,=20PlaylistEditForm=20?=
=?UTF-8?q?and=20PlaylistDumpList=20are=20one=20file=20each,=20and=20LiveD?=
=?UTF-8?q?umpCard=20is=20the=20vote=20wiring=20DumpCardList=20and=20the?=
=?UTF-8?q?=20playlist=20both=20use.=20What=20it=20re-invented=20is=20shar?=
=?UTF-8?q?ed:=20the=20load=20is=20useFetchState,=20a=20dump=20going=20pri?=
=?UTF-8?q?vate=20and=20back=20is=20usePositionAwareSync,=20deletes=20and?=
=?UTF-8?q?=20edits=20are=20useDumpListSync=20(which=20also=20keeps=20comm?=
=?UTF-8?q?ent=20counts,=20which=20the=20page=20didn't).=20A=20removal=20i?=
=?UTF-8?q?s=20sent=20when=20its=20fade=20ends,=20or=20as=20the=20page=20i?=
=?UTF-8?q?s=20left,=20so=20an=20undo=20sends=20nothing:=20the=20removal/r?=
=?UTF-8?q?e-add/reorder=20dance,=20the=20pending-undo=20order=20and=20the?=
=?UTF-8?q?=2080=20ms=20debounce=20of=20its=20echoes=20are=20gone.=20A=20r?=
=?UTF-8?q?efused=20reorder=20goes=20back=20to=20the=20server's=20last=20o?=
=?UTF-8?q?rder=20and=20says=20so,=20instead=20of=20reloading=20the=20page?=
=?UTF-8?q?.=20The=20comments=20written=20since=20the=20documentation=20pa?=
=?UTF-8?q?ss=20broke=20its=20rules=20in=20places:=20module=20docs=20as=20?=
=?UTF-8?q?a=20label=20and=20a=20colon=20("The=20"dumps"=20tab=20of=20a=20?=
=?UTF-8?q?profile:=20=E2=80=A6"),=20noun-phrase=20fragments=20on=20types,?=
=?UTF-8?q?=20fields=20and=20constants,=20stale=20lists=20of=20callers,=20?=
=?UTF-8?q?over-long=20lines;=20they=20are=20sentences=20now,=20and=20the?=
=?UTF-8?q?=20ones=20the=20refactor=20made=20wrong=20(PlaylistDetail=20as?=
=?UTF-8?q?=20useFading's=20and=20FollowPlaylistButton's=20caller,=20"the?=
=?UTF-8?q?=20profiles"=20as=20usePositionAwareSync's=20only=20users)=20sa?=
=?UTF-8?q?y=20what=20is=20true.=20650=20frontend=20and=20214=20backend=20?=
=?UTF-8?q?tests=20pass=20(new:=20usePagedList,=20useFetchState's=20initia?=
=?UTF-8?q?l,=20pageUrl/readPage,=20the=20deferred=20removal,=20a=20dump?=
=?UTF-8?q?=20returning=20at=20its=20place=20in=20a=20playlist),=20and=20t?=
=?UTF-8?q?sc,=20eslint,=20doc:lint,=20deno=20check,=20deno=20lint=20and?=
=?UTF-8?q?=20deno=20fmt=20are=20clean.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
api/config.ts | 10 +-
api/db/connection.ts | 4 +-
api/db/migrate.ts | 8 +-
api/db/startup.ts | 9 +-
api/lib/reserved-slugs.ts | 10 +-
api/lib/static.ts | 2 +-
api/remote/fetch.ts | 2 +-
api/remote/providers/bandcamp.ts | 7 +-
api/services/attachment-service.ts | 5 +-
api/services/backlink-service.ts | 5 +-
api/services/ws-service.ts | 4 +-
shared/api.ts | 6 +-
shared/validation.ts | 4 +-
shared/ws.ts | 18 +-
src/components/dump/DumpCardList.tsx | 60 +-
src/components/feed/DumpFeed.tsx | 32 +-
src/components/feed/FeedFooter.tsx | 4 +-
src/components/feed/FollowedFeed.tsx | 110 +--
src/components/feed/JournalFeed.tsx | 6 +-
src/components/form/SegmentedField.tsx | 6 +-
src/components/playlist/PlaylistCardList.tsx | 6 +-
src/components/playlist/PlaylistDumpList.tsx | 128 +++
src/components/playlist/PlaylistEditForm.tsx | 220 +++++
src/components/playlist/PlaylistHeader.tsx | 101 ++
src/components/profile/DescriptionEditor.tsx | 3 +-
src/components/profile/ProfileDumpsTab.tsx | 5 +-
src/components/profile/ProfileFollowedTab.tsx | 4 +-
src/components/profile/ProfileInviteesTab.tsx | 4 +-
src/components/profile/ProfileManageTab.tsx | 4 +-
src/components/profile/ProfileOwnActions.tsx | 4 +-
.../profile/ProfilePlaylistsTab.tsx | 5 +-
src/components/profile/ProfileSection.tsx | 6 +-
src/components/profile/ProfileSettingsTab.tsx | 4 +-
src/components/profile/SettingRow.tsx | 2 +-
src/components/shared/LoadMoreButton.tsx | 4 +-
src/components/social/FollowButton.tsx | 2 +-
src/components/social/UserListPopover.tsx | 108 +-
src/contexts/category/CategoriesProvider.tsx | 2 +-
src/contexts/social/FollowContext.ts | 5 +-
src/contexts/social/FollowProvider.tsx | 2 +-
src/contexts/theme/ThemeContext.ts | 12 +-
src/contexts/ws/WSContext.ts | 8 +-
src/contexts/ws/WSProvider.tsx | 12 +-
src/hooks/dump/useDumpListSync.ts | 7 +-
src/hooks/dump/useUpvotedList.ts | 23 +-
src/hooks/dump/useUserDumpFeed.ts | 124 +--
src/hooks/feed/useDefaultFeedTab.ts | 4 +-
src/hooks/feed/useFading.ts | 12 +-
src/hooks/feed/useFeedCache.ts | 43 +-
src/hooks/feed/useInfiniteScroll.ts | 3 +-
src/hooks/feed/usePagedList.ts | 107 ++
src/hooks/feed/usePositionAwareSync.ts | 8 +-
src/hooks/playlist/usePlaylist.ts | 334 +++++++
src/hooks/profile/useProfile.ts | 39 +-
src/hooks/shared/useFetchState.ts | 60 +-
src/locales/en.js | 2 +-
src/locales/en.po | 208 ++--
src/locales/fr.js | 2 +-
src/locales/fr.po | 208 ++--
src/pages/Dump.tsx | 4 +-
src/pages/DumpEdit.tsx | 6 +-
src/pages/Index.tsx | 150 +--
src/pages/Notifications.tsx | 167 +---
src/pages/PlaylistDetail.tsx | 927 ++----------------
src/pages/Search.tsx | 250 ++---
src/pages/UserDumps.tsx | 4 +-
src/pages/UserPlaylists.tsx | 186 ++--
src/pages/UserPublicProfile.tsx | 70 +-
src/pages/UserUpvoted.tsx | 2 +-
.../social/UserListPopover.test.tsx | 2 +-
src/test/hooks/dump/useUserDumpFeed.test.tsx | 12 +-
src/test/hooks/feed/useFeedCache.test.tsx | 16 +-
src/test/hooks/feed/usePagedList.test.tsx | 64 ++
src/test/hooks/shared/useFetchState.test.tsx | 18 +-
src/test/pages/PlaylistDetail.test.tsx | 80 +-
src/test/pages/UserPublicProfile.test.tsx | 6 +-
src/test/utils/feed/pagedList.test.ts | 27 +-
src/utils/feed/pagedList.ts | 72 +-
src/utils/shared/api.ts | 6 +-
src/utils/shared/loadState.ts | 4 +-
src/utils/shared/visited.ts | 6 +-
81 files changed, 2016 insertions(+), 2210 deletions(-)
create mode 100644 src/components/playlist/PlaylistDumpList.tsx
create mode 100644 src/components/playlist/PlaylistEditForm.tsx
create mode 100644 src/components/playlist/PlaylistHeader.tsx
create mode 100644 src/hooks/feed/usePagedList.ts
create mode 100644 src/hooks/playlist/usePlaylist.ts
create mode 100644 src/test/hooks/feed/usePagedList.test.tsx
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 (
+
+ >
+ );
+}
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.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"],"0> followed your playlist <1>",["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"],"0> upvoted <1>",["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"],"0> commented on <1>",["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 files0>"],"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"],"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"],"0> was added to <1>",["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"],"0> liked your comment on <1>",["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"],"0> posted <1>",["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"],"0> mentioned you in <1>",["where"],"1>"],"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 in0>"],"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"],"0> followed your playlist <1>",["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"],"0> upvoted <1>",["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"],"0> commented on <1>",["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 files0>"],"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"],"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"],"0> was added to <1>",["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"],"0> liked your comment on <1>",["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"],"0> posted <1>",["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"],"0> mentioned you in <1>",["where"],"1>"],"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 in0>"],"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}0> commented on <1>{1}1>"
msgstr "<0>{0}0> commented on <1>{1}1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:172
+#: src/pages/Notifications.tsx:159
msgid "<0>{0}0> followed your playlist <1>{1}1>"
msgstr "<0>{0}0> followed your playlist <1>{1}1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:222
+#: src/pages/Notifications.tsx:209
msgid "<0>{0}0> liked your comment on <1>{1}1>"
msgstr "<0>{0}0> liked your comment on <1>{1}1>"
#. placeholder {0}: d.mentionerUsername
-#: src/pages/Notifications.tsx:238
+#: src/pages/Notifications.tsx:225
msgid "<0>{0}0> mentioned you in <1>{where}1>"
msgstr "<0>{0}0> mentioned you in <1>{where}1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:182
+#: src/pages/Notifications.tsx:169
msgid "<0>{0}0> posted <1>{1}1>"
msgstr "<0>{0}0> posted <1>{1}1>"
#. placeholder {0}: d.followerUsername
-#: src/pages/Notifications.tsx:163
+#: src/pages/Notifications.tsx:150
msgid "<0>{0}0> started following you"
msgstr "<0>{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}0> upvoted <1>{1}1>"
msgstr "<0>{0}0> upvoted <1>{1}1>"
#. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:192
+#: src/pages/Notifications.tsx:179
msgid "<0>{0}0> was added to <1>{1}1>"
msgstr "<0>{0}0> was added to <1>{1}1>"
@@ -147,11 +147,11 @@ msgstr "<0>{0}0> was added to <1>{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"],"0> a suivi votre collection <1>",["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"],"0> a voté pour <1>",["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"],"0> a commenté sur <1>",["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 fichiers0>"],"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"],"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"],"0> a été ajouté à <1>",["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"],"0> a aimé votre commentaire sur <1>",["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"],"0> a publié <1>",["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"],"0> vous a mentionné dans <1>",["where"],"1>"],"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 connecter0>"],"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"],"0> a suivi votre collection <1>",["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"],"0> a voté pour <1>",["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"],"0> a commenté sur <1>",["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 fichiers0>"],"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"],"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"],"0> a été ajouté à <1>",["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"],"0> a aimé votre commentaire sur <1>",["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"],"0> a publié <1>",["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"],"0> vous a mentionné dans <1>",["where"],"1>"],"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 connecter0>"],"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}0> commented on <1>{1}1>"
msgstr "<0>{0}0> a commenté sur <1>{1}1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:172
+#: src/pages/Notifications.tsx:159
msgid "<0>{0}0> followed your playlist <1>{1}1>"
msgstr "<0>{0}0> a suivi votre collection <1>{1}1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:222
+#: src/pages/Notifications.tsx:209
msgid "<0>{0}0> liked your comment on <1>{1}1>"
msgstr "<0>{0}0> a aimé votre commentaire sur <1>{1}1>"
#. placeholder {0}: d.mentionerUsername
-#: src/pages/Notifications.tsx:238
+#: src/pages/Notifications.tsx:225
msgid "<0>{0}0> mentioned you in <1>{where}1>"
msgstr "<0>{0}0> vous a mentionné dans <1>{where}1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:182
+#: src/pages/Notifications.tsx:169
msgid "<0>{0}0> posted <1>{1}1>"
msgstr "<0>{0}0> a publié <1>{1}1>"
#. placeholder {0}: d.followerUsername
-#: src/pages/Notifications.tsx:163
+#: src/pages/Notifications.tsx:150
msgid "<0>{0}0> started following you"
msgstr "<0>{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}0> upvoted <1>{1}1>"
msgstr "<0>{0}0> a voté pour <1>{1}1>"
#. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:192
+#: src/pages/Notifications.tsx:179
msgid "<0>{0}0> was added to <1>{1}1>"
msgstr "<0>{0}0> a été ajouté à <1>{1}1>"
@@ -147,11 +147,11 @@ msgstr "<0>{0}0> a été ajouté à <1>{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.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