v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<void> {
|
||||
// Dates are stored as ISO strings and compared as text, so the cutoffs are
|
||||
|
||||
@@ -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[] = [
|
||||
"~",
|
||||
|
||||
@@ -38,7 +38,7 @@ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
export function routeStaticFilesFrom(
|
||||
staticPaths: string[],
|
||||
): (context: Context<Record<string, object>>, next: Next) => Promise<void> {
|
||||
// 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<Record<string, object>>, _next: Next) => {
|
||||
const pathname = context.request.url.pathname;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
18
shared/ws.ts
18
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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Renders a list of dumps as cards, wired to the live votes.
|
||||
* Renders dumps as cards wired to the live votes, one at a time or as a list.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,39 +10,57 @@ import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import { DumpCard } from "./DumpCard.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link DumpCardList} by every list of dump cards: the index's
|
||||
* "hot", "new" and "followed" feeds, the search results, a dump's related
|
||||
* dumps, and a profile's dumps and upvotes.
|
||||
* Passed to {@link LiveDumpCard} by {@link DumpCardList}, and by
|
||||
* `PlaylistDumpList`, which wraps each card in its drag handle and buttons.
|
||||
*/
|
||||
export interface DumpCardListProps {
|
||||
dumps: Dump[];
|
||||
/** A class for one card, as `fadeClassName` gives a fading one. */
|
||||
cardClassName?: (dump: Dump) => string | undefined;
|
||||
export interface LiveDumpCardProps {
|
||||
dump: Dump;
|
||||
/** It comes from `fadeClassName` for a fading card. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `dumps` as a `.dump-feed` of {@link DumpCard}. The vote counts and
|
||||
* calls come from `WSContext`, a dump missing from its counts showing its
|
||||
* fetched `voteCount`. Voting takes a signed-in user, and a card is its
|
||||
* owner's when it is the signed-in user's own dump.
|
||||
* Renders `dump` as a {@link DumpCard}, an `<li>`. The vote counts and calls
|
||||
* come from `WSContext`, a dump missing from its counts showing its fetched
|
||||
* `voteCount`. Voting takes a signed-in user, and a card is its owner's when
|
||||
* it is the signed-in user's own dump.
|
||||
*/
|
||||
export function DumpCardList(
|
||||
{ dumps, cardClassName }: DumpCardListProps,
|
||||
export function LiveDumpCard(
|
||||
{ dump, className }: LiveDumpCardProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
return (
|
||||
<DumpCard
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Passed to {@link DumpCardList} by every plain list of dump cards. */
|
||||
export interface DumpCardListProps {
|
||||
dumps: Dump[];
|
||||
/** Gives a card a class, as `fadeClassName` gives a fading one. */
|
||||
cardClassName?: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
/** Renders `dumps` as a `.dump-feed` of {@link LiveDumpCard}. */
|
||||
export function DumpCardList(
|
||||
{ dumps, cardClassName }: DumpCardListProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
<DumpCard
|
||||
<LiveDumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
className={cardClassName?.(dump)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "hot" and "new" tabs of the index page. `Index` fetches the feed and
|
||||
* passes it down as {@link MainFeedProps}.
|
||||
* Renders the "hot" and "new" tabs of the index page, from the feed `Index`
|
||||
* loads and passes down as {@link MainFeedProps}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,30 +11,32 @@ import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { FeedFooter } from "./FeedFooter.tsx";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { LoadState } from "../../utils/shared/loadState.ts";
|
||||
import type { PagedList } from "../../utils/feed/pagedList.ts";
|
||||
import type { FetchState } from "../../hooks/shared/useFetchState.ts";
|
||||
import { hotScore } from "../../utils/feed/hotScore.ts";
|
||||
|
||||
/**
|
||||
* Held by `Index` for its feed, and by `FollowedFeed` for each of its
|
||||
* sections: the pages of dumps loaded so far.
|
||||
* Held by `usePagedList` for `Index`'s feed and for each section of
|
||||
* `FollowedFeed`.
|
||||
*/
|
||||
export type DumpFeedState = LoadState<{
|
||||
dumps: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
}>;
|
||||
export type DumpFeedState = FetchState<PagedList<Dump>>;
|
||||
|
||||
/**
|
||||
* Passed by `Index` to whichever of the "hot", "new" and "journal" tabs is
|
||||
* showing. The tabs share this one state, so switching tabs doesn't refetch.
|
||||
*/
|
||||
export interface MainFeedProps {
|
||||
/** The feed's status and paging. Its own dumps are in `dumps`. */
|
||||
/**
|
||||
* The tabs read its status and paging, and show `dumps` instead of its
|
||||
* items.
|
||||
*/
|
||||
state: DumpFeedState;
|
||||
/** The loaded dumps, with those `WSContext` added since and less deleted. */
|
||||
/**
|
||||
* It holds the loaded dumps, with those `WSContext` added since and without
|
||||
* the deleted ones.
|
||||
*/
|
||||
dumps: Dump[];
|
||||
/** From `useInfiniteScroll`. The feed places it after the last card. */
|
||||
/** It comes from `useInfiniteScroll`, and goes after the last card. */
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ export function DumpFeed(
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ hasMore, loadingMore }) => (
|
||||
{({ data: { hasMore, loadingMore } }) => (
|
||||
<>
|
||||
<DumpCardList dumps={sorted} />
|
||||
<FeedFooter
|
||||
|
||||
@@ -9,12 +9,12 @@ import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/**
|
||||
* Passed to {@link FeedFooter} by every list that loads its next page on
|
||||
* scroll: the index's feeds, the search results and a profile's full lists.
|
||||
* scroll.
|
||||
*/
|
||||
export interface FeedFooterProps {
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
/** From `useInfiniteScroll`. */
|
||||
/** It comes from `useInfiniteScroll`. */
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* The "followed" tab of the index page, which shows the dumps of the users and
|
||||
* the playlists the signed-in user follows.
|
||||
* Renders the "followed" tab of the index page, which shows the dumps of the
|
||||
* users and the playlists the signed-in user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -13,19 +13,19 @@ import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { FeedFooter } from "./FeedFooter.tsx";
|
||||
import type { DumpFeedState } from "./DumpFeed.tsx";
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
|
||||
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||
import { useScrollSave } from "../../hooks/feed/useScrollSave.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
hydrateDump,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
@@ -38,97 +38,33 @@ function useFollowedSection(
|
||||
section: FollowedSection,
|
||||
categorySlug: string | undefined,
|
||||
): { state: DumpFeedState; loadMore: () => void } {
|
||||
const { token } = useAuth();
|
||||
const catQuery = categorySlug
|
||||
? `&category=${encodeURIComponent(categorySlug)}`
|
||||
: "";
|
||||
const { user, authFetch } = useAuth();
|
||||
const url = `${API_URL}/api/follows/feed/${section}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:followed-${section}:${categorySlug ?? "all"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [state, setState] = useState<DumpFeedState>(() =>
|
||||
cached
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }),
|
||||
[],
|
||||
const { state, setItems, loadMore } = usePagedList(
|
||||
user ? `${url}:${user.id}` : undefined,
|
||||
(page, signal) =>
|
||||
authFetch(pageUrl(url, page), { signal })
|
||||
.then((res) => readPage(res, deserializeDump)),
|
||||
cached,
|
||||
);
|
||||
useDumpListSync(setItems);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || state.status !== "loading") return;
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/${section}?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<Dump>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
setState({ status: "error", error: friendlyFetchError(err) })
|
||||
);
|
||||
}, [token, state.status, section, catQuery]);
|
||||
|
||||
// Only the list is kept here: `Index` saves and restores the page's scroll
|
||||
// for every tab, this one included.
|
||||
// Only the list is kept here, since `Index` saves and restores the page's
|
||||
// scroll for every tab, this one included.
|
||||
useScrollSave(
|
||||
state.status === "loaded",
|
||||
useCallback(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveState(state.dumps, state.page, state.hasMore, 0);
|
||||
if (state.status === "loaded") saveState(state.data, 0);
|
||||
}, [state, saveState]),
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||
!token
|
||||
) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/${section}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<Dump>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [state, token, section, catQuery]);
|
||||
|
||||
return { state, loadMore };
|
||||
}
|
||||
|
||||
@@ -144,18 +80,18 @@ function FollowedSubFeed(
|
||||
) {
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
onLoadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
state.status === "loaded" && canLoadMore(state.data),
|
||||
);
|
||||
|
||||
return (
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ dumps }) => dumps.length === 0}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={<p className="index-status">{emptyMessage}</p>}
|
||||
>
|
||||
{({ dumps, hasMore, loadingMore }) => (
|
||||
{({ data: { items, hasMore, loadingMore } }) => (
|
||||
<>
|
||||
<DumpCardList dumps={dumps} />
|
||||
<DumpCardList dumps={items} />
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "journal" tab of the index page, which lays the feed out as a mosaic of
|
||||
* tiles of several sizes.
|
||||
* Renders the "journal" tab of the index page, which lays the feed out as a
|
||||
* mosaic of tiles of several sizes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -37,7 +37,7 @@ export function JournalFeed(
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ hasMore, loadingMore }) => (
|
||||
{({ data: { hasMore, loadingMore } }) => (
|
||||
<>
|
||||
{
|
||||
/* Each block is its own grid, with a themable <hr> between them.
|
||||
|
||||
@@ -59,7 +59,7 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
);
|
||||
}
|
||||
|
||||
/** The Public/Private options, true for public. */
|
||||
// Public is true.
|
||||
const VISIBILITY_OPTIONS: SegmentedOption<boolean>[] = [
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
{ value: false, label: <Trans>Private</Trans> },
|
||||
@@ -95,8 +95,8 @@ export function VisibilityToggle<T extends FieldValues>({
|
||||
|
||||
/**
|
||||
* Renders a Public/Private {@link Segmented} outside `SegmentedField`, for
|
||||
* `PlaylistDetail`'s edit form, which binds it with its own `Controller` and
|
||||
* styles it apart.
|
||||
* `PlaylistEditForm`, which binds it with its own `Controller` and styles it
|
||||
* apart.
|
||||
*/
|
||||
export function VisibilitySegmented(
|
||||
props: Omit<SegmentedProps<boolean>, "options">,
|
||||
|
||||
@@ -8,11 +8,7 @@ import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { PlaylistCard } from "./PlaylistCard.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link PlaylistCardList} by every list of playlist cards: the
|
||||
* profile's and `UserPlaylists`' lists, the search results and a dump's
|
||||
* collections.
|
||||
*/
|
||||
/** Passed to {@link PlaylistCardList} by every list of playlist cards. */
|
||||
export interface PlaylistCardListProps {
|
||||
playlists: Playlist[];
|
||||
/** Given, each card shows a delete button, which calls it. */
|
||||
|
||||
128
src/components/playlist/PlaylistDumpList.tsx
Normal file
128
src/components/playlist/PlaylistDumpList.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Renders the dumps of a playlist's page, which those who may edit it drag
|
||||
* into a new order and remove.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { fadeClassName, type FadeState } from "../../hooks/feed/useFading.ts";
|
||||
import { LiveDumpCard } from "../dump/DumpCardList.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link PlaylistDumpList} by `PlaylistDetail`, from `usePlaylist`.
|
||||
* The calls are given only to those who may edit the playlist.
|
||||
*/
|
||||
export interface PlaylistDumpListProps {
|
||||
dumps: Dump[];
|
||||
fading: Record<string, FadeState>;
|
||||
onRemove?: (dumpId: string) => void;
|
||||
onUndoRemove?: (dumpId: string) => void;
|
||||
/** Counts only the dumps not fading, which alone can be dragged. */
|
||||
onMove?: (from: number, to: number) => void;
|
||||
/** Called as a drag ends, to save the order `onMove` left. */
|
||||
onMoved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dumps, or a line saying there are none. With the calls, each
|
||||
* dump not fading has a drag handle and a remove button, and one fading an
|
||||
* "Undo" during its cooldown.
|
||||
*/
|
||||
export function PlaylistDumpList(
|
||||
{ dumps, fading, onRemove, onUndoRemove, onMove, onMoved }:
|
||||
PlaylistDumpListProps,
|
||||
): React.JSX.Element {
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
if (dumps.length === 0) {
|
||||
return (
|
||||
<p className="empty-state">
|
||||
<Trans>No dumps in this playlist yet.</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// Swapping as soon as the pointer enters a card would swap it straight
|
||||
// back, so the pointer must cross the card's middle first.
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (src < index && e.clientY < mid) return;
|
||||
if (src > index && e.clientY > mid) return;
|
||||
onMove?.(src, index);
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
const dragged = dragSrcRef.current !== null;
|
||||
dragSrcRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
if (dragged) onMoved?.();
|
||||
};
|
||||
|
||||
const canDrag = !!onMove;
|
||||
let activeIndex = 0;
|
||||
return (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={canDrag ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{dumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const index = phase ? -1 : activeIndex++;
|
||||
const draggable = canDrag && !phase;
|
||||
return (
|
||||
<div
|
||||
key={dump.id}
|
||||
className={`playlist-dump-item${
|
||||
draggable && index === dragOverIndex
|
||||
? " playlist-dump-item--drag-over"
|
||||
: ""
|
||||
}`}
|
||||
draggable={draggable}
|
||||
onDragStart={draggable
|
||||
? () => {
|
||||
dragSrcRef.current = index;
|
||||
}
|
||||
: undefined}
|
||||
onDragOver={draggable ? (e) => handleDragOver(e, index) : undefined}
|
||||
onDragEnd={canDrag ? handleDragEnd : undefined}
|
||||
>
|
||||
{draggable && <span className="drag-handle" aria-hidden>⠿</span>}
|
||||
<LiveDumpCard dump={dump} className={fadeClassName(phase)} />
|
||||
{!phase && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-remove-btn"
|
||||
onClick={() => onRemove(dump.id)}
|
||||
aria-label={t`Remove from playlist`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{phase === "cooldown" && onUndoRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
onClick={() => onUndoRemove(dump.id)}
|
||||
aria-label={t`Cancel removal`}
|
||||
>
|
||||
<Trans>Undo</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
src/components/playlist/PlaylistEditForm.tsx
Normal file
220
src/components/playlist/PlaylistEditForm.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Renders the form that edits a playlist's details and cover, in place of the
|
||||
* header of its page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
UpdatePlaylistRequest,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
|
||||
import { playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { CountedInput } from "../shared/CountedInput.tsx";
|
||||
import { ImagePicker } from "../shared/ImagePicker.tsx";
|
||||
import { TextEditor } from "../editor/TextEditor.tsx";
|
||||
import { VisibilitySegmented } from "../form/SegmentedField.tsx";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
|
||||
/** Passed to {@link PlaylistEditForm} by `PlaylistDetail`. */
|
||||
export interface PlaylistEditFormProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
onCancel: () => void;
|
||||
/** Opens the page's confirmation, which deletes the playlist. */
|
||||
onRequestDelete: () => void;
|
||||
/** Called with the playlist as the server now has it. */
|
||||
onSaved: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
image: File | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that edits the playlist, in place of the page's header. It
|
||||
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
|
||||
* title and description that changed, then a new cover, if one was picked,
|
||||
* with `POST /api/playlists/:playlistId/image`.
|
||||
*/
|
||||
export function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
): React.JSX.Element {
|
||||
const { authFetch, mediaToken } = useAuth();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
// Each preview holds the file in memory until its URL is revoked: when the
|
||||
// next one replaces it, or when the form closes.
|
||||
useEffect(() => {
|
||||
if (!imagePreview) return;
|
||||
return () => URL.revokeObjectURL(imagePreview);
|
||||
}, [imagePreview]);
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: playlist.title,
|
||||
description: playlist.description ?? "",
|
||||
isPublic: playlist.isPublic,
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
const description = form.watch("description");
|
||||
const title = form.watch("title");
|
||||
|
||||
const onSubmit = form.submit(
|
||||
async ({ title, description, isPublic, image }) => {
|
||||
const updated = deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
...(title !== playlist.title ? { title } : {}),
|
||||
...(description !== (playlist.description ?? "")
|
||||
? { description: description || null }
|
||||
: {}),
|
||||
isPublic,
|
||||
} satisfies UpdatePlaylistRequest,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (image) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", image);
|
||||
// The upload answers with the playlist as it now stands (new cover,
|
||||
// new updatedAt), which is what the page should show.
|
||||
onSaved(deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(updated);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{
|
||||
/* `display: contents` keeps the header's layout, the cover beside the
|
||||
fields, around a real <form>, which Enter submits. */
|
||||
}
|
||||
<form style={{ display: "contents" }} onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? playlistImageUrl(playlist, mediaToken)
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
className="animate-in"
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="playlist-detail-content playlist-edit-content animate-in">
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={t`Playlist title`}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
onCancel={onCancel}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={2}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!title.trim() ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
101
src/components/playlist/PlaylistHeader.tsx
Normal file
101
src/components/playlist/PlaylistHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Renders the header of a playlist's page, with its cover, title, description,
|
||||
* visibility, owner and dates.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
|
||||
import { playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
||||
export interface PlaylistHeaderProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
/** Given to those who may edit it, for whom it adds an "Edit" button. */
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the playlist's details, with a follow button for anyone but its
|
||||
* owner. `PlaylistEditForm` takes its place while it is edited.
|
||||
*/
|
||||
export function PlaylistHeader(
|
||||
{ playlist, onEdit }: PlaylistHeaderProps,
|
||||
): React.JSX.Element {
|
||||
const { user, mediaToken } = useAuth();
|
||||
return (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={playlistImageUrl(playlist, mediaToken)}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{user?.id !== playlist.userId && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
<span
|
||||
className={`playlist-badge${
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && (
|
||||
<Link
|
||||
to={`/users/${playlist.ownerUsername}`}
|
||||
className="playlist-detail-owner"
|
||||
>
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||
<time dateTime={playlist.createdAt.toISOString()}>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{playlist.updatedAt && (
|
||||
<Tooltip text={t`Edited ${playlist.updatedAt.toLocaleString()}`}>
|
||||
<span className="playlist-edited-label">
|
||||
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,8 @@ export function DescriptionEditor(
|
||||
|
||||
const onSubmit = form.submit(async ({ description }) => {
|
||||
const trimmed = description.trim();
|
||||
// Sent even when empty: left out, the server would keep the old one.
|
||||
// Sent even when empty, since the server keeps the old one when it is left
|
||||
// out.
|
||||
await expectOk<Wire<PublicUser>>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The "dumps" tab of a profile: the user's dumps beside those they upvoted.
|
||||
* Renders the "dumps" tab of a profile, which shows the user's dumps beside
|
||||
* those they upvoted.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,7 +19,7 @@ export interface ProfileDumpsTabProps {
|
||||
dumps: PagedList<Dump>;
|
||||
votes: PagedList<Dump>;
|
||||
isOwnProfile: boolean;
|
||||
/** From `useProfile`, which fades out a withdrawn upvote. */
|
||||
/** It comes from `useProfile`, which fades out a withdrawn upvote. */
|
||||
upvotedCardClassName: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "followed" tab of a profile: the users and the playlists the user
|
||||
* follows.
|
||||
* Renders the "followed" tab of a profile, which shows the users and the
|
||||
* playlists the user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "invitees" tab of a profile: whom the user invited, and whom they
|
||||
* invited in turn.
|
||||
* Renders the "invitees" tab of a profile, which shows whom the user invited,
|
||||
* and whom they invited in turn.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "manage" tab of a profile, which holds tools for another user on their
|
||||
* profile and site-wide tools on one's own.
|
||||
* Renders the "manage" tab of a profile, which holds tools for another user on
|
||||
* their profile and site-wide tools on one's own.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Renders the actions of the signed-in user's own profile header: inviting
|
||||
* someone, and whatever the header adds, the log out button.
|
||||
* Renders the actions of the signed-in user's own profile header, which invite
|
||||
* someone, beside the log out button the header adds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The "playlists" tab of a profile: the playlists the user made.
|
||||
* Renders the "playlists" tab of a profile, which shows the playlists the user
|
||||
* made.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ export interface ProfilePlaylistsTabProps {
|
||||
username: string;
|
||||
playlists: PagedList<Playlist>;
|
||||
isOwnProfile: boolean;
|
||||
/** From `useProfile`, to put a playlist made here first. */
|
||||
/** It comes from `useProfile`, and puts a playlist made here first. */
|
||||
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import { Trans } from "@lingui/react/macro";
|
||||
*/
|
||||
export interface ProfileSectionProps {
|
||||
title: ReactNode;
|
||||
/** Buttons at the end of the title row. */
|
||||
/** They go at the end of the title row. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* The page with the whole list, of which the section shows the first page.
|
||||
* The profile leaves it out while the list is empty.
|
||||
* It links to the page with the whole list, of which the section shows the
|
||||
* first page. The profile leaves it out while the list is empty.
|
||||
*/
|
||||
viewAllHref?: string;
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The "settings" tab of the signed-in user's own profile: their password, and
|
||||
* this browser's preferences.
|
||||
* Renders the "settings" tab of the signed-in user's own profile, where they
|
||||
* change their password and this browser's preferences.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ReactNode } from "react";
|
||||
*/
|
||||
export interface SettingRowProps {
|
||||
label: ReactNode;
|
||||
/** The control, usually a `Segmented`. */
|
||||
/** It is the row's control, usually a `Segmented`. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/**
|
||||
* Passed to {@link LoadMoreButton} by `Notifications`, `UserListPopover` and
|
||||
* the profile's followed users, which render it while there is more to load.
|
||||
* Passed to {@link LoadMoreButton} by the lists that load their next page on a
|
||||
* click, which render it while there is more to load.
|
||||
*/
|
||||
export interface LoadMoreButtonProps {
|
||||
loadingMore: boolean;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface FollowUserButtonProps {
|
||||
targetUsername: string;
|
||||
}
|
||||
|
||||
/** Passed to {@link FollowPlaylistButton} by the `PlaylistDetail` page. */
|
||||
/** Passed to {@link FollowPlaylistButton} by `PlaylistHeader`. */
|
||||
export interface FollowPlaylistButtonProps {
|
||||
targetPlaylistId: string;
|
||||
/** Hides the button, since only a public playlist can be followed. */
|
||||
|
||||
@@ -8,16 +8,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { LoadMoreButton } from "../shared/LoadMoreButton.tsx";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||
import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
|
||||
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link UserListPopover} by the button whose count was clicked. The
|
||||
@@ -28,23 +25,12 @@ export interface UserListPopoverProps {
|
||||
/** Called on an outside click, on Escape, and on a scroll outside it. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* A paginated endpoint answering `PaginatedData<PublicUser>`, without a query
|
||||
* string: the popover appends `page` and `limit`.
|
||||
* It answers `PaginatedData<PublicUser>`, a page at a time, and the popover
|
||||
* adds `page` and `limit` to its query.
|
||||
*/
|
||||
fetchUrl: string;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
users: PublicUser[];
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
// In viewport coordinates. `bottom` replaces `top` when the popover opens above
|
||||
// the anchor, so that it grows upward.
|
||||
interface Placement {
|
||||
@@ -69,8 +55,13 @@ const MIN_USABLE_HEIGHT = 100;
|
||||
export function UserListPopover(
|
||||
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
|
||||
): React.ReactPortal | null {
|
||||
const { token } = useAuth();
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const { user, authFetch } = useAuth();
|
||||
const { state, loadMore } = usePagedList(
|
||||
`${fetchUrl}:${user?.id ?? "guest"}`,
|
||||
(page, signal) =>
|
||||
authFetch(pageUrl(fetchUrl, page), { signal })
|
||||
.then((res) => readPage(res, deserializePublicUser)),
|
||||
);
|
||||
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -139,71 +130,6 @@ export function UserListPopover(
|
||||
};
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(`${fetchUrl}?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const apiResponse = parseAPIResponse<PaginatedData<Wire<PublicUser>>>(
|
||||
body,
|
||||
);
|
||||
if (!apiResponse.success) {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
status: "loaded",
|
||||
users: apiResponse.data.items.map(deserializePublicUser),
|
||||
page: 1,
|
||||
hasMore: apiResponse.data.hasMore,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({ status: "error", error: t`Could not load.` });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [fetchUrl, token]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (state.status !== "loaded" || state.loadingMore) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState({ ...state, loadingMore: true });
|
||||
fetch(`${fetchUrl}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const apiResponse = parseAPIResponse<PaginatedData<Wire<PublicUser>>>(
|
||||
body,
|
||||
);
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
if (!apiResponse.success) return { ...prev, loadingMore: false };
|
||||
return {
|
||||
status: "loaded",
|
||||
users: [
|
||||
...prev.users,
|
||||
...apiResponse.data.items.map(deserializePublicUser),
|
||||
],
|
||||
page: nextPage,
|
||||
hasMore: apiResponse.data.hasMore,
|
||||
loadingMore: false,
|
||||
};
|
||||
});
|
||||
})
|
||||
// Offline or an unreadable reply: give the button back for a retry.
|
||||
.catch(() =>
|
||||
setState((prev) =>
|
||||
prev.status === "loaded" ? { ...prev, loadingMore: false } : prev
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (!placement) return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -227,14 +153,14 @@ export function UserListPopover(
|
||||
{state.status === "error" && (
|
||||
<p className="user-list-popover-status">{state.error}</p>
|
||||
)}
|
||||
{state.status === "loaded" && state.users.length === 0 && (
|
||||
{state.status === "loaded" && state.data.items.length === 0 && (
|
||||
<p className="user-list-popover-status">
|
||||
<Trans>No one yet.</Trans>
|
||||
</p>
|
||||
)}
|
||||
{state.status === "loaded" && state.users.length > 0 && (
|
||||
{state.status === "loaded" && state.data.items.length > 0 && (
|
||||
<>
|
||||
{state.users.map((u) => (
|
||||
{state.data.items.map((u) => (
|
||||
<Link
|
||||
key={u.id}
|
||||
to={`/users/${u.username}`}
|
||||
@@ -251,11 +177,11 @@ export function UserListPopover(
|
||||
<span>{u.username}</span>
|
||||
</Link>
|
||||
))}
|
||||
{state.hasMore && (
|
||||
{state.data.hasMore && (
|
||||
<LoadMoreButton
|
||||
className="user-list-popover-load-more"
|
||||
loadingMore={state.loadingMore}
|
||||
onClick={handleLoadMore}
|
||||
loadingMore={state.data.loadingMore}
|
||||
onClick={loadMore}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string>;
|
||||
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;
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
/** 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<string>) => void;
|
||||
@@ -265,7 +267,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
Record<string, AvatarOverride>
|
||||
>({});
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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<Dump>;
|
||||
}>;
|
||||
|
||||
/** 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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -62,7 +67,7 @@ export function useUserDumpFeed(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): UseUserDumpFeedResult {
|
||||
const { token } = useAuth();
|
||||
const { authFetch } = useAuth();
|
||||
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
|
||||
|
||||
const [state, setState] = useState<UserDumpFeedState>({ 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<Dump>) => PagedList<Dump>) =>
|
||||
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<PaginatedData<Wire<Dump>>>(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]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, FadeState>;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T> {
|
||||
/** 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<T> {
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to {@link useFeedCache}'s `saveState` by the feed pages, from the
|
||||
* list they hold, without its loading flag.
|
||||
*/
|
||||
export type SavedList<T> = Pick<PagedList<T>, "items" | "page" | "hasMore">;
|
||||
|
||||
/** Returned by {@link useFeedCache} to a feed page. */
|
||||
export interface UseFeedCacheResult<T> {
|
||||
/**
|
||||
* 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<FeedCacheEntry<T>, "savedAt"> | null;
|
||||
/** Usually called through `useScrollSave`, as the page is left. */
|
||||
saveState: (
|
||||
items: T[],
|
||||
page: number,
|
||||
hasMore: boolean,
|
||||
scrollY: number,
|
||||
) => void;
|
||||
saveState: (list: SavedList<T>, scrollY: number) => void;
|
||||
}
|
||||
|
||||
/** Returns the entry saved under `key`, or null if none is fresh enough. */
|
||||
@@ -89,20 +92,22 @@ export function useFeedCache<T>(
|
||||
}
|
||||
|
||||
const saveState = useCallback(
|
||||
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||
({ items, page, hasMore }: SavedList<T>, 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<T>,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Full or blocked storage: the page just refetches next time.
|
||||
// The page just refetches next time, when storage is full or blocked.
|
||||
}
|
||||
},
|
||||
[fullKey],
|
||||
|
||||
@@ -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.
|
||||
|
||||
107
src/hooks/feed/usePagedList.ts
Normal file
107
src/hooks/feed/usePagedList.ts
Normal file
@@ -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<T> = (
|
||||
page: number,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<PaginatedData<T>>;
|
||||
|
||||
/**
|
||||
* 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<T>(
|
||||
list: PagedList<T> | null,
|
||||
setList: (fn: (list: PagedList<T>) => PagedList<T>) => void,
|
||||
fetchPage: FetchPage<T>,
|
||||
): () => 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<T> {
|
||||
state: FetchState<PagedList<T>>;
|
||||
/** 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<T>(
|
||||
key: string | undefined,
|
||||
fetchPage: FetchPage<T>,
|
||||
cached?: Omit<FeedCacheEntry<T>, "savedAt"> | null,
|
||||
): UsePagedListResult<T> {
|
||||
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 };
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
334
src/hooks/playlist/usePlaylist.ts
Normal file
334
src/hooks/playlist/usePlaylist.ts
Normal file
@@ -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<PlaylistWithDumps>;
|
||||
/**
|
||||
* 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<string, FadeState>;
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T extends { id: string }>(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<string | null>(null);
|
||||
|
||||
// The server's last order of the dumps this viewer may see, which a refused
|
||||
// reorder goes back to.
|
||||
const serverOrderRef = useRef<string[]>([]);
|
||||
const { state, setData } = useFetchState(
|
||||
playlistId && `${playlistId}:${user?.id ?? "guest"}`,
|
||||
(signal) =>
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}`, { signal })
|
||||
.then(expectOk<Wire<PlaylistWithDumps>>)
|
||||
.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<string, { dump: Dump; index: number }>());
|
||||
// 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<string>());
|
||||
|
||||
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<Wire<Dump>>)
|
||||
.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,
|
||||
};
|
||||
}
|
||||
@@ -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<ProfileData>;
|
||||
/** 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<K extends ListKey>(
|
||||
* 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<Dump>(
|
||||
@@ -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 = <T>(list: string, deserialize: (w: Wire<T>) => 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");
|
||||
|
||||
@@ -17,31 +17,58 @@ import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
/** Held by {@link useFetchState}, and passed on to `ListState`. */
|
||||
export type FetchState<T> = LoadState<{ data: T }>;
|
||||
|
||||
/** Passed to {@link useFetchState}. */
|
||||
export interface FetchStateOptions<T> {
|
||||
/**
|
||||
* 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<T> {
|
||||
state: FetchState<T>;
|
||||
/** 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<T>(
|
||||
key: string | undefined,
|
||||
load: (signal: AbortSignal) => Promise<T>,
|
||||
enabled = true,
|
||||
{ enabled = true, initial }: FetchStateOptions<T> = {},
|
||||
): UseFetchStateResult<T> {
|
||||
const [state, setState] = useState<FetchState<T>>({ status: "loading" });
|
||||
const [prevKey, setPrevKey] = useState(key);
|
||||
if (prevKey !== key) {
|
||||
setPrevKey(key);
|
||||
setState({ status: "loading" });
|
||||
const startState = (): FetchState<T> =>
|
||||
initial === undefined
|
||||
? { status: "loading" }
|
||||
: { status: "loaded", data: initial };
|
||||
const [state, setState] = useState<FetchState<T>>(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<T>(
|
||||
}, [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<T>(
|
||||
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) =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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é"
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ export function Dump(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
// Taken once: it stays in the history entry, where a reload or Back would
|
||||
// show that snapshot forever, so it is cleared from there below.
|
||||
// Taken once, since it stays in the history entry, where a reload or Back
|
||||
// would show that snapshot forever. It is cleared from there below.
|
||||
const [handedOver] = useState(
|
||||
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
|
||||
);
|
||||
|
||||
@@ -56,9 +56,9 @@ type DumpEditState =
|
||||
/**
|
||||
* Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators;
|
||||
* anyone else signed in gets an error rather than a form the API would refuse
|
||||
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
|
||||
* and the deletion apply at once, and the form's other fields on "Save", which
|
||||
* goes back to the dump's page. A deletion goes to the index.
|
||||
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the
|
||||
* preview refresh and the deletion apply at once, and the form's other fields
|
||||
* on "Save", which goes back to the dump's page. A deletion goes to the index.
|
||||
*/
|
||||
export function DumpEdit(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
|
||||
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
} from "../config/feedTabs.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
||||
import {
|
||||
useScrollRestore,
|
||||
@@ -37,11 +36,7 @@ import { useCategories } from "../hooks/category/useCategories.ts";
|
||||
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
||||
|
||||
import {
|
||||
DumpFeed,
|
||||
type DumpFeedState,
|
||||
type MainFeedProps,
|
||||
} from "../components/feed/DumpFeed.tsx";
|
||||
import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
|
||||
import { JournalFeed } from "../components/feed/JournalFeed.tsx";
|
||||
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
@@ -71,7 +66,7 @@ export function Index(
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
?.deletedDumpId;
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
|
||||
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
||||
@@ -97,31 +92,26 @@ export function Index(
|
||||
? (rawTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
const feedUrl = useCallback(
|
||||
(page: number) =>
|
||||
categorySlug
|
||||
? `${API_URL}/api/categories/${
|
||||
encodeURIComponent(categorySlug)
|
||||
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}`
|
||||
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
[categorySlug],
|
||||
const feedUrl = categorySlug
|
||||
? `${API_URL}/api/categories/${encodeURIComponent(categorySlug)}/dumps`
|
||||
: `${API_URL}/api/dumps/`;
|
||||
const fetchPage = useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(pageUrl(feedUrl, page), { signal })
|
||||
.then((res) => readPage(res, deserializeDump)),
|
||||
[authFetch, feedUrl],
|
||||
);
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${categorySlug ?? "all"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [dumpsState, setDumpsState] = useState<DumpFeedState>(() =>
|
||||
cached
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
const { state: dumpsState, setItems, loadMore } = usePagedList(
|
||||
`${feedUrl}:${user?.id ?? "guest"}`,
|
||||
fetchPage,
|
||||
cached,
|
||||
);
|
||||
useDumpListSync(setItems);
|
||||
|
||||
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
|
||||
// the redirect from `/` keeps. The query is dropped from the URL at once, so
|
||||
@@ -135,42 +125,6 @@ export function Index(
|
||||
globalThis.history.replaceState({}, "", location.pathname);
|
||||
}, [shareUrl, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(feedUrl(1), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const { items, hasMore } = await expectOk<
|
||||
PaginatedData<Wire<Dump>>
|
||||
>(res);
|
||||
setDumpsState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setDumpsState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [cached, token, feedUrl]);
|
||||
|
||||
const setDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setDumpsState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
useDumpListSync(setDumpsItems);
|
||||
|
||||
// Dumps posted while the socket was down never arrived. On reconnect, the
|
||||
// first page is fetched again and whatever is new goes on top, keeping the
|
||||
// pages loaded and the scroll position.
|
||||
@@ -179,76 +133,34 @@ export function Index(
|
||||
if (connectionEpoch === backfillEpochRef.current) return;
|
||||
backfillEpochRef.current = connectionEpoch;
|
||||
const controller = new AbortController();
|
||||
fetch(feedUrl(1), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items }) => {
|
||||
const fresh = items.map(deserializeDump);
|
||||
setDumpsState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const known = new Set(s.dumps.map((d) => d.id));
|
||||
const newOnes = fresh.filter((d) => !known.has(d.id));
|
||||
if (newOnes.length === 0) return s;
|
||||
return { ...s, dumps: [...newOnes, ...s.dumps] };
|
||||
});
|
||||
})
|
||||
fetchPage(1, controller.signal)
|
||||
.then(({ items }) =>
|
||||
setItems((dumps) => {
|
||||
const known = new Set(dumps.map((d) => d.id));
|
||||
const newOnes = items.filter((d) => !known.has(d.id));
|
||||
return newOnes.length === 0 ? dumps : [...newOnes, ...dumps];
|
||||
})
|
||||
)
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [connectionEpoch, feedUrl, token]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||
dumpsState.loadingMore
|
||||
) return;
|
||||
const nextPage = dumpsState.page + 1;
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(feedUrl(nextPage), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [dumpsState, token, feedUrl]);
|
||||
}, [connectionEpoch, fetchPage, setItems]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||
!dumpsState.loadingMore,
|
||||
dumpsState.status === "loaded" && canLoadMore(dumpsState.data),
|
||||
);
|
||||
|
||||
useScrollSave(
|
||||
dumpsState.status === "loaded",
|
||||
useCallback((y) => {
|
||||
if (dumpsState.status !== "loaded") return;
|
||||
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
|
||||
if (dumpsState.status === "loaded") saveState(dumpsState.data, y);
|
||||
}, [dumpsState, saveState]),
|
||||
);
|
||||
|
||||
useScrollRestore(cached?.scrollY, dumpsState.status === "loaded");
|
||||
|
||||
const dumps = useMemo(
|
||||
() => dumpsState.status === "loaded" ? dumpsState.dumps : [],
|
||||
() => dumpsState.status === "loaded" ? dumpsState.data.items : [],
|
||||
[dumpsState],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -31,23 +31,10 @@ import type {
|
||||
UserFollowedData,
|
||||
UserMentionedData,
|
||||
} from "../../shared/notification.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
import { deserializeNotification } from "../utils/shared/deserialize.ts";
|
||||
import { pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
|
||||
type State =
|
||||
// `arrived` holds what the socket sent before the list came back, which the
|
||||
// list may predate.
|
||||
| { status: "loading"; arrived: Notification[] }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
items: Notification[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type NotifIconKind =
|
||||
| "upvote"
|
||||
@@ -293,94 +280,41 @@ function groupByDate(
|
||||
*/
|
||||
export function Notifications(): React.JSX.Element {
|
||||
useDocumentTitle(t`Notifications`);
|
||||
const { authFetch } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { clearUnreadNotifications, lastNotification } = useWS();
|
||||
const [state, setState] = useState<State>({
|
||||
status: "loading",
|
||||
arrived: [],
|
||||
});
|
||||
const { state, loadMore } = usePagedList(
|
||||
user?.id,
|
||||
(page, signal) =>
|
||||
authFetch(
|
||||
pageUrl(`${API_URL}/api/notifications`, page, NOTIFICATIONS_PAGE_SIZE),
|
||||
{ signal },
|
||||
).then((res) => readPage(res, deserializeNotification)),
|
||||
);
|
||||
|
||||
// The latest notification the read-all requests have covered, so that one
|
||||
// arriving while the page is open is marked read once, and the one the bell
|
||||
// already had isn't marked again after the first load.
|
||||
const lastNotificationRef = useRef(lastNotification);
|
||||
const markedReadRef = useRef(lastNotification);
|
||||
useLayoutEffect(() => {
|
||||
lastNotificationRef.current = lastNotification;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await expectOk<PaginatedData<Wire<Notification>>>(
|
||||
await authFetch(
|
||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const items = data.items.map(deserializeNotification);
|
||||
setState((s) => ({
|
||||
status: "loaded",
|
||||
items: [
|
||||
...(s.status === "loading" ? s.arrived : []).filter((a) =>
|
||||
!items.some((n) => n.id === a.id)
|
||||
),
|
||||
...items,
|
||||
],
|
||||
hasMore: data.hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If this fails, the list is still worth showing, and they stay unread
|
||||
// for next time.
|
||||
markedReadRef.current = lastNotificationRef.current;
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (cancelled || !res.ok) return;
|
||||
// Only the bell clears. Marking the list read too would take its dots
|
||||
// and "new" count away a moment after they appear.
|
||||
clearUnreadNotifications();
|
||||
} catch {
|
||||
// Offline: they stay unread for next time.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authFetch, clearUnreadNotifications]);
|
||||
|
||||
// Each new `lastNotification` is added once, during render rather than in an
|
||||
// effect, so it shows in the same frame.
|
||||
// Each new `lastNotification` is kept once, during render rather than in an
|
||||
// effect, so it shows in the same frame. The list may predate it or not.
|
||||
const [arrived, setArrived] = useState<Notification[]>([]);
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
lastNotification,
|
||||
);
|
||||
if (prevLastNotification !== lastNotification && lastNotification !== null) {
|
||||
setPrevLastNotification(lastNotification);
|
||||
setState((s) => {
|
||||
if (s.status === "loading") {
|
||||
return { ...s, arrived: [lastNotification, ...s.arrived] };
|
||||
}
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.items.some((n) => n.id === lastNotification.id)) return s;
|
||||
return { ...s, items: [lastNotification, ...s.items] };
|
||||
});
|
||||
setArrived((a) => [lastNotification, ...a]);
|
||||
}
|
||||
const items = useMemo(() => {
|
||||
if (state.status !== "loaded") return [];
|
||||
const listed = new Set(state.data.items.map((n) => n.id));
|
||||
return [...arrived.filter((n) => !listed.has(n.id)), ...state.data.items];
|
||||
}, [state, arrived]);
|
||||
|
||||
// One that arrives while the page is open is being seen: it stays
|
||||
// highlighted here, but the bell must not count it.
|
||||
// Every notification is marked read on the server once the list shows, and
|
||||
// again for each that arrives while the page is open. Only the bell clears:
|
||||
// clearing the list too would take its dots and "new" count away a moment
|
||||
// after they appear. A failure leaves them unread for next time.
|
||||
const loaded = state.status === "loaded";
|
||||
const markedReadRef = useRef<Notification | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
if (markedReadRef.current === lastNotification) return;
|
||||
if (!loaded || markedReadRef.current === lastNotification) return;
|
||||
markedReadRef.current = lastNotification;
|
||||
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
|
||||
.then((res) => {
|
||||
@@ -389,39 +323,7 @@ export function Notifications(): React.JSX.Element {
|
||||
.catch(() => {});
|
||||
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||
return;
|
||||
}
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Notification>>>(res))
|
||||
.then((data) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
items: [...s.items, ...data.items.map(deserializeNotification)],
|
||||
hasMore: data.hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const totalUnread = state.status === "loaded"
|
||||
? state.items.filter((n) => !n.read).length
|
||||
: 0;
|
||||
const totalUnread = items.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -447,7 +349,7 @@ export function Notifications(): React.JSX.Element {
|
||||
<ErrorCard title={t`Failed to load`} message={state.error} />
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.items.length === 0 && (
|
||||
{state.status === "loaded" && items.length === 0 && (
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon">🔕</span>
|
||||
<p>
|
||||
@@ -462,8 +364,8 @@ export function Notifications(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.items.length > 0 &&
|
||||
groupByDate(state.items).map(({ label, items }) => (
|
||||
{state.status === "loaded" && items.length > 0 &&
|
||||
groupByDate(items).map(({ label, items }) => (
|
||||
<section key={label} className="notif-group">
|
||||
<h2 className="notif-group-label">
|
||||
{label === "Today"
|
||||
@@ -508,8 +410,11 @@ export function Notifications(): React.JSX.Element {
|
||||
</section>
|
||||
))}
|
||||
|
||||
{state.status === "loaded" && state.hasMore && (
|
||||
<LoadMoreButton loadingMore={state.loadingMore} onClick={loadMore} />
|
||||
{state.status === "loaded" && state.data.hasMore && (
|
||||
<LoadMoreButton
|
||||
loadingMore={state.data.loadingMore}
|
||||
onClick={loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
@@ -5,557 +5,57 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { CountedInput } from "../components/shared/CountedInput.tsx";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
ReorderPlaylistRequest,
|
||||
UpdatePlaylistRequest,
|
||||
} from "../../shared/playlist.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylist,
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../utils/shared/urls.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
||||
import { fadeClassName, useFading } from "../hooks/feed/useFading.ts";
|
||||
import { useWS } from "../hooks/ws/useWS.ts";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { usePlaylist } from "../hooks/playlist/usePlaylist.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { TextEditor } from "../components/editor/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { VisibilitySegmented } from "../components/form/SegmentedField.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { FormError } from "../components/form/FormError.tsx";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; playlist: PlaylistWithDumps };
|
||||
import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
|
||||
import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
|
||||
import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx";
|
||||
|
||||
/**
|
||||
* Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see
|
||||
* the playlist: a private one only to its owner. The playlist and the dumps
|
||||
* the viewer may see in it come from `GET /api/playlists/:playlistId`, and the
|
||||
* socket keeps them live. Its owner, or a user with `playlist:moderate`, can
|
||||
* edit or delete it, drag its dumps into a new order, and remove one, with a
|
||||
* short window to undo.
|
||||
* the playlist: a private one only to its owner. `usePlaylist` loads it and
|
||||
* keeps it live. Its owner, or a user with `playlist:moderate`, can edit or
|
||||
* delete it, drag its dumps into a new order, and remove one, with a short
|
||||
* window to undo.
|
||||
*/
|
||||
export function PlaylistDetail(): React.JSX.Element {
|
||||
const { playlistId } = useParams<{ playlistId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch, token, mediaToken } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
// The socket names playlists by id, while the URL may hold the slug.
|
||||
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||
|
||||
useDocumentTitle(
|
||||
state.status === "loaded"
|
||||
? state.playlist.title
|
||||
: state.status === "error"
|
||||
? null
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// The dumps in the playlist. `state.playlist.dumps` also keeps the removed
|
||||
// ones until their fade ends.
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// A removed dump shows faded for 2 seconds, while it can be undone, then
|
||||
// slides out, as on the upvoted page, and leaves `state.playlist.dumps`.
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading(
|
||||
(id) =>
|
||||
setState((prev) =>
|
||||
prev.status !== "loaded" ? prev : {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
|
||||
},
|
||||
}
|
||||
),
|
||||
);
|
||||
// An undo re-adds the dump, which the server puts first, then restores its
|
||||
// place. This holds that place's order until both requests are done, so the
|
||||
// socket's echo of the first doesn't flash the dump at the top.
|
||||
const pendingUndoOrderRef = useRef<string[] | null>(null);
|
||||
// Other clients see the same two echoes without that order, so a new order
|
||||
// from the socket is applied only once none follows for 80 ms.
|
||||
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
state,
|
||||
fading,
|
||||
actionError,
|
||||
applyDetails,
|
||||
remove,
|
||||
undoRemove,
|
||||
move,
|
||||
saveOrder,
|
||||
deletePlaylist,
|
||||
} = usePlaylist(playlistId);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// A delete or a removal the server refused. A refused reorder just reloads.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// For the socket effects, which must not re-run when it changes.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// Every dump of the playlist seen so far, so that one hidden when it turned
|
||||
// private comes back when it turns public. A dump removed from the playlist
|
||||
// leaves it.
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// The removals still in flight, by dump, which an undo must wait for.
|
||||
const removalsRef = useRef<Map<string, Promise<unknown>>>(new Map());
|
||||
// The server's last order, where such a dump comes back.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeDumpIdsRef.current = activeDumpIds;
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchPlaylist = useCallback(() => {
|
||||
if (!playlistId) return;
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
// In a microtask, since the linter forbids a synchronous setState in the
|
||||
// effect that calls this. The user sees no difference.
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
|
||||
);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load playlist");
|
||||
const pl = deserializePlaylistWithDumps(
|
||||
body.data as Wire<PlaylistWithDumps>,
|
||||
);
|
||||
setState({ status: "loaded", playlist: pl });
|
||||
const ids = new Set(pl.dumps.map((d) => d.id));
|
||||
const order = pl.dumps.map((d) => d.id);
|
||||
setActiveDumpIds(ids);
|
||||
dumpOrderRef.current = order;
|
||||
for (const id of ids) knownDumpIdsRef.current.add(id);
|
||||
cancelAll();
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
dumpReorderTimerRef.current = null;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setState({
|
||||
status: "error",
|
||||
error: friendlyFetchError(err),
|
||||
});
|
||||
});
|
||||
}, [playlistId, token, cancelAll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [fetchPlaylist]);
|
||||
|
||||
// The socket's changes to this playlist: its dumps, its details, its end.
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
if (ev.playlistId !== playlistUUID) return;
|
||||
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const newIds = new Set(ev.dumpIds);
|
||||
for (const id of newIds) knownDumpIdsRef.current.add(id);
|
||||
const prev = activeDumpIdsRef.current;
|
||||
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
// Shown until now and gone from the list: removed from the
|
||||
// playlist, not hidden (that comes as `dump_deleted`).
|
||||
knownDumpIdsRef.current.delete(id);
|
||||
setActiveDumpIds((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
startFading(id);
|
||||
}
|
||||
}
|
||||
|
||||
// An added dump is fetched alone. fetchPlaylist would go back through
|
||||
// "loading", which unsets playlistUUID and runs this effect again.
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
cancelFading(id);
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((body) => {
|
||||
if (!body?.success) return;
|
||||
const dump = deserializeDump(body.data as Wire<Dump>);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
return {
|
||||
...s,
|
||||
playlist: {
|
||||
...s.playlist,
|
||||
dumps: [
|
||||
...orderedIds
|
||||
.filter((oid) => dumpMap.has(oid))
|
||||
.map((oid) => dumpMap.get(oid)!),
|
||||
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced: see dumpReorderTimerRef.
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
}
|
||||
const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
|
||||
const serverOrder = ev.dumpIds!;
|
||||
dumpReorderTimerRef.current = setTimeout(() => {
|
||||
dumpReorderTimerRef.current = null;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
const orderedActive = orderToApply
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!);
|
||||
let ai = 0;
|
||||
// The fading dumps keep their slots, the others take the new order,
|
||||
// and dumps whose fetch landed late go last.
|
||||
const merged = s.playlist.dumps.map((d) =>
|
||||
newIds.has(d.id) ? orderedActive[ai++] : d
|
||||
);
|
||||
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
|
||||
return { ...s, playlist: { ...s.playlist, dumps: merged } };
|
||||
});
|
||||
dumpOrderRef.current = serverOrder;
|
||||
}, 80);
|
||||
} else if (ev.type === "updated" && ev.playlist) {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
title: ev.playlist!.title,
|
||||
description: ev.playlist!.description,
|
||||
isPublic: ev.playlist!.isPublic,
|
||||
imageMime: ev.playlist!.imageMime,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
}
|
||||
}, [
|
||||
lastPlaylistEvent,
|
||||
playlistUUID,
|
||||
navigate,
|
||||
token,
|
||||
startFading,
|
||||
cancelFading,
|
||||
]);
|
||||
|
||||
// A deleted dump leaves at once, without the removal's fade.
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
// It mirrors the socket's state, which only an effect can do.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const filtered = prev.playlist.dumps.filter((d) =>
|
||||
!deletedDumpIds.has(d.id)
|
||||
);
|
||||
if (filtered.length === prev.playlist.dumps.length) return prev;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
|
||||
});
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
for (const id of deletedDumpIds) n.delete(id);
|
||||
return n;
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
// An edited dump changes in place, and one of the playlist's dumps that
|
||||
// turned public again comes back at its place in the server's order.
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
const dump = lastDumpEvent;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||
if (idx !== -1) {
|
||||
const dumps = [...prev.playlist.dumps];
|
||||
dumps[idx] = dump;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||
}
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
const order = dumpOrderRef.current;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
const reinserted = order.length > 0
|
||||
? [
|
||||
...order.filter((id) => dumpMap.has(id)).map((id) =>
|
||||
dumpMap.get(id)!
|
||||
),
|
||||
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
|
||||
]
|
||||
: [...prev.playlist.dumps, dump];
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
setActiveDumpIds((prev) => {
|
||||
if (prev.has(dump.id)) return prev;
|
||||
return new Set([...prev, dump.id]);
|
||||
});
|
||||
}
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
dragSrcRef.current = index;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// Swapping as soon as the pointer enters a card would swap it straight
|
||||
// back, so the pointer must cross the card's middle first.
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (src < index && e.clientY < mid) return;
|
||||
if (src > index && e.clientY > mid) return;
|
||||
// The drag indices count only the dumps not fading, which go last.
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const ids = activeDumpIdsRef.current;
|
||||
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
|
||||
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
|
||||
const reordered = [...activeDumps];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
const src = dragSrcRef.current;
|
||||
dragSrcRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
if (src === null || state.status !== "loaded" || !playlistId) return;
|
||||
const activeDumps = state.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/order`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
dumpIds: activeDumps.map((d) => d.id),
|
||||
} satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
// Back to the order the server actually kept.
|
||||
fetchPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
const index = state.playlist.dumps.findIndex((d) => d.id === dumpId);
|
||||
const dump = state.playlist.dumps[index];
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
// Removed on the server at once; the undo adds it back.
|
||||
const removal = authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`,
|
||||
{ method: "DELETE" },
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
cancelFading(dumpId);
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// The fade may have taken it out of the list already.
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || !dump) return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dumpId)) return s;
|
||||
const dumps = [...s.playlist.dumps];
|
||||
dumps.splice(Math.min(index, dumps.length), 0, dump);
|
||||
return { ...s, playlist: { ...s.playlist, dumps } };
|
||||
});
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
}).finally(() => removalsRef.current.delete(dumpId));
|
||||
removalsRef.current.set(dumpId, removal);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFading(dumpId);
|
||||
};
|
||||
|
||||
const handleCancelRemove = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
cancelFading(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// A fading dump keeps its slot in `state.playlist.dumps`, so that order is
|
||||
// the one to restore.
|
||||
const restoredIds = new Set([...activeDumpIds, dumpId]);
|
||||
const desiredOrder = state.playlist.dumps
|
||||
.filter((d) => restoredIds.has(d.id))
|
||||
.map((d) => d.id);
|
||||
pendingUndoOrderRef.current = desiredOrder;
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
// Added back only once the removal has landed, or the two could cross.
|
||||
// The server adds the dump first, so the order is restored after.
|
||||
(removalsRef.current.get(dumpId) ?? Promise.resolve())
|
||||
.then(() =>
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
.then((res) => {
|
||||
// 409: the removal failed, so it never left.
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFading(dumpId);
|
||||
setActionError(t`The dump couldn't be put back. Try again.`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingUndoOrderRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = () => setEditOpen(true);
|
||||
|
||||
const handleEditSaved = (updated: Playlist) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo, which may never
|
||||
// come (disconnected); a new slug also moves the URL.
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
...updated,
|
||||
dumps: prev.playlist.dumps,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!playlistId) return;
|
||||
setConfirmDelete(false);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// expectOk throws the server's message.
|
||||
if (!res.ok) await expectOk(res);
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
};
|
||||
useDocumentTitle(
|
||||
state.status === "loaded"
|
||||
? state.data.title
|
||||
: state.status === "error"
|
||||
? null
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
@@ -584,15 +84,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { playlist } = state;
|
||||
const isOwner = !!user && user.id === playlist.userId;
|
||||
const canEdit = isOwner || can(user, "playlist:moderate");
|
||||
|
||||
// The fading dumps stay on screen, but can't be dragged.
|
||||
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
|
||||
const visibleDumps = playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id) || d.id in fading
|
||||
);
|
||||
const playlist = state.data;
|
||||
const canEdit = (!!user && user.id === playlist.userId) ||
|
||||
can(user, "playlist:moderate");
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -604,81 +98,19 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
playlist={playlist}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={handleEditSaved}
|
||||
onSaved={(updated) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo,
|
||||
// which may never come (disconnected).
|
||||
applyDetails(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={playlistImageUrl(playlist, mediaToken)}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{!isOwner && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
<span
|
||||
className={`playlist-badge${
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic
|
||||
? <Trans>public</Trans>
|
||||
: <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && (
|
||||
<Link
|
||||
to={`/users/${playlist.ownerUsername}`}
|
||||
className="playlist-detail-owner"
|
||||
>
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||
<time dateTime={playlist.createdAt.toISOString()}>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{playlist.updatedAt && (
|
||||
<Tooltip
|
||||
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
|
||||
>
|
||||
<span className="playlist-edited-label">
|
||||
<Trans>
|
||||
edited {relativeTime(playlist.updatedAt)}
|
||||
</Trans>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<PlaylistHeader
|
||||
playlist={playlist}
|
||||
onEdit={canEdit ? () => setEditOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -687,277 +119,28 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
<ErrorCard title={t`Something went wrong`} message={actionError} />
|
||||
)}
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No dumps in this playlist yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={canEdit ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{visibleDumps.map((dump) => {
|
||||
const isActive = activeDumpIds.has(dump.id);
|
||||
const phase = fading[dump.id];
|
||||
// drag index is within the active-only list
|
||||
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
|
||||
<PlaylistDumpList
|
||||
dumps={playlist.dumps}
|
||||
fading={fading}
|
||||
{...(canEdit && {
|
||||
onRemove: remove,
|
||||
onUndoRemove: undoRemove,
|
||||
onMove: move,
|
||||
onMoved: saveOrder,
|
||||
})}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dump.id}
|
||||
className={`playlist-dump-item${
|
||||
activeIndex === dragOverIndex && isActive
|
||||
? " playlist-dump-item--drag-over"
|
||||
: ""
|
||||
}`}
|
||||
draggable={canEdit && isActive}
|
||||
onDragStart={canEdit && isActive
|
||||
? () => handleDragStart(activeIndex)
|
||||
: undefined}
|
||||
onDragOver={canEdit && isActive
|
||||
? (e) => handleDragOver(e, activeIndex)
|
||||
: undefined}
|
||||
onDragEnd={canEdit ? handleDragEnd : undefined}
|
||||
>
|
||||
{canEdit && isActive && (
|
||||
<span className="drag-handle" aria-hidden>⠿</span>
|
||||
)}
|
||||
<DumpCard
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={fadeClassName(phase)}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
/>
|
||||
{canEdit && (isActive
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-remove-btn"
|
||||
onClick={() => handleRemoveDump(dump.id)}
|
||||
aria-label={t`Remove from playlist`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)
|
||||
: phase === "cooldown" && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
onClick={() => handleCancelRemove(dump.id)}
|
||||
aria-label={t`Cancel removal`}
|
||||
>
|
||||
<Trans>Undo</Trans>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this playlist? This cannot be undone.`}
|
||||
confirmLabel={t`Delete playlist`}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
void deletePlaylist();
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Passed to {@link PlaylistEditForm} by {@link PlaylistDetail}. */
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
onCancel: () => void;
|
||||
/** Opens the page's confirmation, which deletes the playlist. */
|
||||
onRequestDelete: () => void;
|
||||
/** Called with the playlist as the server now has it. */
|
||||
onSaved: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
image: File | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that edits the playlist, in place of the page's header. It
|
||||
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
|
||||
* title and description that changed, then a new cover, if one was picked,
|
||||
* with `POST /api/playlists/:playlistId/image`.
|
||||
*/
|
||||
function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
) {
|
||||
const { authFetch, mediaToken } = useAuth();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
// Each preview holds the file in memory until its URL is revoked: when the
|
||||
// next one replaces it, or when the form closes.
|
||||
useEffect(() => {
|
||||
if (!imagePreview) return;
|
||||
return () => URL.revokeObjectURL(imagePreview);
|
||||
}, [imagePreview]);
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: playlist.title,
|
||||
description: playlist.description ?? "",
|
||||
isPublic: playlist.isPublic,
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
const description = form.watch("description");
|
||||
const title = form.watch("title");
|
||||
|
||||
const onSubmit = form.submit(
|
||||
async ({ title, description, isPublic, image }) => {
|
||||
const updated = deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
...(title !== playlist.title ? { title } : {}),
|
||||
...(description !== (playlist.description ?? "")
|
||||
? { description: description || null }
|
||||
: {}),
|
||||
isPublic,
|
||||
} satisfies UpdatePlaylistRequest,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (image) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", image);
|
||||
// The upload answers with the playlist as it now stands (new cover,
|
||||
// new updatedAt), which is what the page should show.
|
||||
onSaved(deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(updated);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{
|
||||
/* `display: contents` keeps the image-beside-content flex layout while
|
||||
still giving us a real <form> for submit + Enter handling. */
|
||||
}
|
||||
<form style={{ display: "contents" }} onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? playlistImageUrl(playlist, mediaToken)
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
className="animate-in"
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="playlist-detail-content playlist-edit-content animate-in">
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={t`Playlist title`}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
onCancel={onCancel}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={2}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!title.trim() ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -28,30 +28,32 @@ import type { Dump } from "../../shared/dump.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
|
||||
import { friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
import { SEARCH_URL } from "../config/api.ts";
|
||||
import { expectOk } from "../utils/shared/api.ts";
|
||||
import {
|
||||
canLoadMore,
|
||||
firstPage,
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
} from "../utils/feed/pagedList.ts";
|
||||
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||
|
||||
const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
||||
type Tab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
// Only the dumps are paged: the API answers at most 20 users and playlists.
|
||||
type SearchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
q: string;
|
||||
dumps: {
|
||||
items: Dump[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
users: PublicUser[];
|
||||
playlists: Playlist[];
|
||||
};
|
||||
/**
|
||||
* Held by `Search` for a query, from the first page of `SearchResults`. The
|
||||
* dumps take the next pages.
|
||||
*/
|
||||
interface Results {
|
||||
dumps: PagedList<Dump>;
|
||||
/** It counts every match, which the pages loaded so far may not reach. */
|
||||
dumpTotal: number;
|
||||
users: PublicUser[];
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come
|
||||
@@ -65,131 +67,49 @@ export function Search(): React.JSX.Element {
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps");
|
||||
|
||||
const { token } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const search = useCallback(async (page: number, signal?: AbortSignal) => {
|
||||
const url = new URL(SEARCH_URL, location.origin);
|
||||
url.searchParams.set("q", q);
|
||||
return expectOk<Wire<SearchResults>>(
|
||||
await authFetch(pageUrl(url.toString(), page), { signal }),
|
||||
);
|
||||
}, [authFetch, q]);
|
||||
|
||||
const [state, setState] = useState<SearchState>({ status: "idle" });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSearch = useCallback(async (query: string, page: number) => {
|
||||
// Deferred: the set-state-in-effect lint rule forbids setting state
|
||||
// synchronously in the effect that calls this.
|
||||
if (!query.trim()) {
|
||||
queueMicrotask(() => setState({ status: "idle" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === 1) {
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const url = new URL(SEARCH_URL, location.origin);
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("page", String(page));
|
||||
url.searchParams.set("limit", String(DEFAULT_PAGE_SIZE));
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const body = await res.json() as {
|
||||
success: true;
|
||||
data: {
|
||||
dumps: { items: Wire<Dump>[]; total: number; hasMore: boolean };
|
||||
users: Wire<PublicUser>[];
|
||||
playlists: Wire<Playlist>[];
|
||||
};
|
||||
};
|
||||
|
||||
const { data } = body;
|
||||
|
||||
if (page === 1) {
|
||||
setState({
|
||||
status: "loaded",
|
||||
q: query,
|
||||
dumps: {
|
||||
items: data.dumps.items.map(deserializeDump),
|
||||
total: data.dumps.total,
|
||||
hasMore: data.dumps.hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
},
|
||||
users: data.users.map(deserializePublicUser),
|
||||
playlists: data.playlists.map(deserializePlaylist),
|
||||
});
|
||||
} else {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
dumps: {
|
||||
...prev.dumps,
|
||||
items: [
|
||||
...prev.dumps.items,
|
||||
...data.dumps.items.map(deserializeDump),
|
||||
],
|
||||
hasMore: data.dumps.hasMore,
|
||||
page,
|
||||
loadingMore: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
// A later page that fails leaves the results already shown; scrolling
|
||||
// back to the end tries it again.
|
||||
if (page > 1) {
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { ...prev, dumps: { ...prev.dumps, loadingMore: false } }
|
||||
: prev
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
// The lint rule flags the call itself, whatever fetchSearch defers inside.
|
||||
queueMicrotask(() => fetchSearch(q, 1));
|
||||
return () => abortRef.current?.abort();
|
||||
}, [q, fetchSearch]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.dumps.hasMore ||
|
||||
state.dumps.loadingMore
|
||||
) return;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
|
||||
});
|
||||
fetchSearch(q, state.dumps.page + 1);
|
||||
}, [state, q, fetchSearch]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore &&
|
||||
!state.dumps.loadingMore,
|
||||
const { state, setData } = useFetchState<Results>(
|
||||
q.trim() ? `${q}:${user?.id ?? "guest"}` : undefined,
|
||||
(signal) =>
|
||||
search(1, signal).then(({ dumps, users, playlists }) => ({
|
||||
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
|
||||
dumpTotal: dumps.total ?? 0,
|
||||
users: users.map(deserializePublicUser),
|
||||
playlists: playlists.map(deserializePlaylist),
|
||||
})),
|
||||
);
|
||||
|
||||
const dumpCount = state.status === "loaded" ? state.dumps.total : null;
|
||||
const userCount = state.status === "loaded" ? state.users.length : null;
|
||||
const playlistCount = state.status === "loaded"
|
||||
? state.playlists.length
|
||||
: null;
|
||||
const dumps = state.status === "loaded" ? state.data.dumps : null;
|
||||
const loadMore = useLoadMore(
|
||||
dumps,
|
||||
useCallback(
|
||||
(fn: (dumps: PagedList<Dump>) => PagedList<Dump>) =>
|
||||
setData((d) => ({ ...d, dumps: fn(d.dumps) })),
|
||||
[setData],
|
||||
),
|
||||
(page) =>
|
||||
search(page).then(({ dumps }) => ({
|
||||
items: dumps.items.map(deserializeDump),
|
||||
hasMore: dumps.hasMore,
|
||||
})),
|
||||
);
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
tab === "dumps" && canLoadMore(dumps),
|
||||
);
|
||||
|
||||
const results = state.status === "loaded" ? state.data : null;
|
||||
const dumpCount = results ? results.dumpTotal : null;
|
||||
const userCount = results ? results.users.length : null;
|
||||
const playlistCount = results ? results.playlists.length : null;
|
||||
|
||||
function tabLabel(tab: Tab, count: number | null) {
|
||||
const label = tab === "dumps"
|
||||
@@ -224,13 +144,13 @@ export function Search(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.status === "idle" && (
|
||||
{!q.trim() && (
|
||||
<p className="search-page-empty">
|
||||
<Trans>Enter a query to search.</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "loading" && (
|
||||
{q.trim() && state.status === "loading" && (
|
||||
<p className="search-page-empty">
|
||||
<Trans>Searching…</Trans>
|
||||
</p>
|
||||
@@ -240,29 +160,27 @@ export function Search(): React.JSX.Element {
|
||||
<ErrorCard title={t`Search failed`} message={state.error} />
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && tab === "dumps" && (
|
||||
<>
|
||||
{state.dumps.items.length === 0
|
||||
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
||||
: (
|
||||
<>
|
||||
<DumpCardList dumps={state.dumps.items} />
|
||||
<FeedFooter
|
||||
hasMore={state.dumps.hasMore}
|
||||
loadingMore={state.dumps.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{results && tab === "dumps" && (
|
||||
results.dumps.items.length === 0
|
||||
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
||||
: (
|
||||
<>
|
||||
<DumpCardList dumps={results.dumps.items} />
|
||||
<FeedFooter
|
||||
hasMore={results.dumps.hasMore}
|
||||
loadingMore={results.dumps.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && tab === "users" && (
|
||||
state.users.length === 0
|
||||
{results && tab === "users" && (
|
||||
results.users.length === 0
|
||||
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
|
||||
: (
|
||||
<ul className="user-results">
|
||||
{state.users.map((u) => (
|
||||
{results.users.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
to={`/users/${u.username}`}
|
||||
@@ -281,14 +199,14 @@ export function Search(): React.JSX.Element {
|
||||
)
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && tab === "playlists" && (
|
||||
state.playlists.length === 0
|
||||
{results && tab === "playlists" && (
|
||||
results.playlists.length === 0
|
||||
? (
|
||||
<p className="search-page-empty">
|
||||
{t`No playlists match "${q}".`}
|
||||
</p>
|
||||
)
|
||||
: <PlaylistCardList playlists={state.playlists} />
|
||||
: <PlaylistCardList playlists={results.playlists} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function UserDumps(): React.JSX.Element {
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const dumpItems = state.status === "loaded" ? state.items : [];
|
||||
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
|
||||
usePositionAwareSync(
|
||||
dumpItems,
|
||||
setItems,
|
||||
@@ -78,7 +78,7 @@ export function UserDumps(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, items: dumps, hasMore, loadingMore } = state;
|
||||
const { profileUser, dumps: { items: dumps, hasMore, loadingMore } } = state;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -10,9 +10,8 @@ import { Link, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
import {
|
||||
@@ -31,11 +30,16 @@ import {
|
||||
useScrollSave,
|
||||
} from "../hooks/feed/useScrollSave.ts";
|
||||
import {
|
||||
fetchFirstPage,
|
||||
canLoadMore,
|
||||
mapItems,
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
readPage,
|
||||
restoredList,
|
||||
withFirst,
|
||||
} from "../utils/feed/pagedList.ts";
|
||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||
import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts";
|
||||
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||
@@ -67,7 +71,7 @@ type State =
|
||||
*/
|
||||
export function UserPlaylists(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, token } = useAuth();
|
||||
const { user: me, authFetch } = useAuth();
|
||||
|
||||
useDocumentTitle(username ?? null);
|
||||
const { lastPlaylistEvent } = useWS();
|
||||
@@ -96,13 +100,31 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
||||
);
|
||||
}, []);
|
||||
type SetList = (
|
||||
fn: (list: PagedList<Playlist>) => PagedList<Playlist>,
|
||||
) => void;
|
||||
const setCreatedList = useCallback<SetList>(
|
||||
(fn) =>
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, created: fn(s.created) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
const setFollowedList = useCallback<SetList>(
|
||||
(fn) =>
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, followed: fn(s.followed) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
const setCreated = useCallback(
|
||||
(fn: (prev: Playlist[]) => Playlist[]) => setCreatedList(mapItems(fn)),
|
||||
[setCreatedList],
|
||||
);
|
||||
const setFollowed = useCallback(
|
||||
(fn: (prev: Playlist[]) => Playlist[]) => setFollowedList(mapItems(fn)),
|
||||
[setFollowedList],
|
||||
);
|
||||
const createdItems = state.status === "loaded" ? state.created.items : [];
|
||||
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||
? (lastPlaylistEvent.playlist ?? null)
|
||||
@@ -120,35 +142,29 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
ownerId: profileUserId ?? undefined,
|
||||
});
|
||||
|
||||
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
|
||||
);
|
||||
}, []);
|
||||
usePlaylistListSync(setFollowed, { noNewEntries: true });
|
||||
|
||||
const createdUrl = `${API_URL}/api/users/${username}/playlists`;
|
||||
const followedUrl = `${API_URL}/api/users/${username}/followed-playlists`;
|
||||
const fetchPage = (url: string) => (page: number) =>
|
||||
authFetch(pageUrl(url, page))
|
||||
.then((res) => readPage(res, deserializePlaylist));
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const firstPageOf = (url: string) =>
|
||||
authFetch(pageUrl(url, 1), { signal })
|
||||
.then((res) => readFirstPage(res, deserializePlaylist));
|
||||
|
||||
// Back on the page, the lists come from the cache and only the user is
|
||||
// fetched again.
|
||||
const cached = cachedCreated && cachedFollowed;
|
||||
Promise.all([
|
||||
fetchPublicUser(username, signal),
|
||||
cached ? restoredList(cachedCreated) : fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/playlists`,
|
||||
deserializePlaylist,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
|
||||
),
|
||||
cached ? restoredList(cachedFollowed) : fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/followed-playlists`,
|
||||
deserializePlaylist,
|
||||
{ signal },
|
||||
),
|
||||
cached ? restoredList(cachedCreated) : firstPageOf(createdUrl),
|
||||
cached ? restoredList(cachedFollowed) : firstPageOf(followedUrl),
|
||||
])
|
||||
.then(([profileUser, created, followed]) =>
|
||||
setState({ status: "loaded", profileUser, created, followed })
|
||||
@@ -158,114 +174,32 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [username, cachedCreated, cachedFollowed, token]);
|
||||
|
||||
const loadMoreCreated = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.created.hasMore ||
|
||||
state.created.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.created.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
created: {
|
||||
items: [...s.created.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMoreFollowed = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.followed.hasMore ||
|
||||
state.followed.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.followed.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
followed: {
|
||||
items: [...s.followed.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username]);
|
||||
}, [
|
||||
username,
|
||||
cachedCreated,
|
||||
cachedFollowed,
|
||||
authFetch,
|
||||
createdUrl,
|
||||
followedUrl,
|
||||
]);
|
||||
|
||||
const createdList = state.status === "loaded" ? state.created : null;
|
||||
const followedList = state.status === "loaded" ? state.followed : null;
|
||||
const createdSentinelRef = useInfiniteScroll(
|
||||
loadMoreCreated,
|
||||
state.status === "loaded" && state.created.hasMore &&
|
||||
!state.created.loadingMore,
|
||||
useLoadMore(createdList, setCreatedList, fetchPage(createdUrl)),
|
||||
canLoadMore(createdList),
|
||||
);
|
||||
const followedSentinelRef = useInfiniteScroll(
|
||||
loadMoreFollowed,
|
||||
state.status === "loaded" && state.followed.hasMore &&
|
||||
!state.followed.loadingMore,
|
||||
useLoadMore(followedList, setFollowedList, fetchPage(followedUrl)),
|
||||
canLoadMore(followedList),
|
||||
);
|
||||
|
||||
useScrollSave(
|
||||
state.status === "loaded",
|
||||
useCallback((y) => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveCreated(
|
||||
state.created.items,
|
||||
state.created.page,
|
||||
state.created.hasMore,
|
||||
y,
|
||||
);
|
||||
saveFollowed(
|
||||
state.followed.items,
|
||||
state.followed.page,
|
||||
state.followed.hasMore,
|
||||
y,
|
||||
);
|
||||
saveCreated(state.created, y);
|
||||
saveFollowed(state.followed, y);
|
||||
}, [state, saveCreated, saveFollowed]),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,20 +5,27 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Invitee, PublicUser } from "../../shared/user.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import {
|
||||
deserializeInvitee,
|
||||
deserializePlaylist,
|
||||
deserializePublicUser,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { dataOr, expectOk } from "../utils/shared/api.ts";
|
||||
import { dataOr } from "../utils/shared/api.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { fetchFirstPage } from "../utils/feed/pagedList.ts";
|
||||
import {
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
readPage,
|
||||
} from "../utils/feed/pagedList.ts";
|
||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
||||
@@ -92,22 +99,19 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
|
||||
|
||||
const loaded = state.status === "loaded";
|
||||
const followedUsersUrl = `${API_URL}/api/users/${username}/followed-users`;
|
||||
const followed = useFetchState<ProfileFollowed>(
|
||||
username,
|
||||
(signal) =>
|
||||
Promise.all([
|
||||
fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/followed-users`,
|
||||
deserializePublicUser,
|
||||
fetch(pageUrl(followedUsersUrl, 1), { signal })
|
||||
.then((res) => readFirstPage(res, deserializePublicUser)),
|
||||
fetch(
|
||||
pageUrl(`${API_URL}/api/users/${username}/followed-playlists`, 1),
|
||||
{ signal },
|
||||
),
|
||||
fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/followed-playlists`,
|
||||
deserializePlaylist,
|
||||
{ signal },
|
||||
),
|
||||
).then((res) => readFirstPage(res, deserializePlaylist)),
|
||||
]).then(([users, playlists]) => ({ users, playlists })),
|
||||
loaded && tab === "followed",
|
||||
{ enabled: loaded && tab === "followed" },
|
||||
);
|
||||
const invitees = useFetchState<Invitee[]>(
|
||||
username,
|
||||
@@ -115,33 +119,21 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
fetch(`${API_URL}/api/users/${username}/invitees`, { signal })
|
||||
.then((res) => dataOr<Wire<Invitee>[]>(res, []))
|
||||
.then((entries) => entries.map(deserializeInvitee)),
|
||||
loaded && tab === "invitees",
|
||||
{ enabled: loaded && tab === "invitees" },
|
||||
);
|
||||
|
||||
const loadMoreFollowedUsers = () => {
|
||||
if (
|
||||
followed.state.status !== "loaded" ||
|
||||
followed.state.data.users.loadingMore
|
||||
) return;
|
||||
const nextPage = followed.state.data.users.page + 1;
|
||||
const setUsers = (
|
||||
fn: (u: ProfileFollowed["users"]) => ProfileFollowed["users"],
|
||||
) => followed.setData((d) => ({ ...d, users: fn(d.users) }));
|
||||
setUsers((u) => ({ ...u, loadingMore: true }));
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<PublicUser>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setUsers((u) => ({
|
||||
items: [...u.items, ...items.map(deserializePublicUser)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}))
|
||||
)
|
||||
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
|
||||
};
|
||||
const setFollowed = followed.setData;
|
||||
const loadMoreFollowedUsers = useLoadMore(
|
||||
followed.state.status === "loaded" ? followed.state.data.users : null,
|
||||
useCallback(
|
||||
(fn: (users: PagedList<PublicUser>) => PagedList<PublicUser>) =>
|
||||
setFollowed((d) => ({ ...d, users: fn(d.users) })),
|
||||
[setFollowed],
|
||||
),
|
||||
(page) =>
|
||||
fetch(pageUrl(followedUsersUrl, page))
|
||||
.then((res) => readPage(res, deserializePublicUser)),
|
||||
);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, items: votes, hasMore, loadingMore } = state;
|
||||
const { profileUser, dumps: { items: votes, hasMore, loadingMore } } = state;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("UserListPopover", () => {
|
||||
first.unmount();
|
||||
route("GET /api/x/voters", () => new TypeError("offline"));
|
||||
renderWithContexts(<Harness />);
|
||||
await screen.findByText("Could not load.");
|
||||
await screen.findByText(/Could not connect to the server/);
|
||||
});
|
||||
|
||||
test("a failed next page lets you try again", async () => {
|
||||
|
||||
@@ -46,8 +46,8 @@ describe("useUserDumpFeed", () => {
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.profileUser.username).toBe("ann");
|
||||
expect(s.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(s.hasMore).toBe(true);
|
||||
expect(s.dumps.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(s.dumps.hasMore).toBe(true);
|
||||
const [req] = calls("GET /api/users/:u/dumps");
|
||||
expect(req.url.searchParams.get("page")).toBe("1");
|
||||
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
@@ -91,7 +91,7 @@ describe("useUserDumpFeed", () => {
|
||||
act(() => obs.report(true));
|
||||
await waitFor(() => {
|
||||
const s = result.current.state;
|
||||
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
|
||||
expect(s.status === "loaded" && s.dumps.items.map((d) => d.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
@@ -100,8 +100,8 @@ describe("useUserDumpFeed", () => {
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.hasMore).toBe(false);
|
||||
expect(s.page).toBe(2);
|
||||
expect(s.dumps.hasMore).toBe(false);
|
||||
expect(s.dumps.page).toBe(2);
|
||||
});
|
||||
|
||||
test("changing user resets to loading", async () => {
|
||||
@@ -130,6 +130,6 @@ describe("useUserDumpFeed", () => {
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.items.map((d) => d.id)).toEqual(["b"]);
|
||||
expect(s.dumps.items.map((d) => d.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,13 @@ describe("useFeedCache", () => {
|
||||
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
|
||||
act(() =>
|
||||
first.result.current.saveState({
|
||||
items: [{ n: 1 }],
|
||||
page: 2,
|
||||
hasMore: true,
|
||||
}, 300)
|
||||
);
|
||||
const second = renderHook(
|
||||
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
|
||||
{ wrapper: wrapper() },
|
||||
@@ -40,7 +46,9 @@ describe("useFeedCache", () => {
|
||||
const first = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([], 1, false, 0));
|
||||
act(() =>
|
||||
first.result.current.saveState({ items: [], page: 1, hasMore: false }, 0)
|
||||
);
|
||||
vi.advanceTimersByTime(11 * 60 * 1000);
|
||||
const second = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
@@ -92,6 +100,8 @@ describe("useFeedCache", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
|
||||
expect(() =>
|
||||
result.current.saveState({ items: [], page: 1, hasMore: false }, 0)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
64
src/test/hooks/feed/usePagedList.test.tsx
Normal file
64
src/test/hooks/feed/usePagedList.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePagedList } from "../../../hooks/feed/usePagedList.ts";
|
||||
import { deferred } from "../../fetch.ts";
|
||||
|
||||
describe("usePagedList", () => {
|
||||
test("loads the first page, then appends the next ones", async () => {
|
||||
const fetchPage = vi.fn((page: number) =>
|
||||
Promise.resolve({ items: [page], hasMore: page < 2 })
|
||||
);
|
||||
const { result } = renderHook(() => usePagedList("k", fetchPage));
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
act(() => result.current.loadMore());
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toEqual({
|
||||
status: "loaded",
|
||||
data: { items: [1, 2], hasMore: false, page: 2, loadingMore: false },
|
||||
})
|
||||
);
|
||||
act(() => result.current.loadMore());
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("loads one next page at a time, and a failed one can be tried again", async () => {
|
||||
const next = deferred<{ items: number[]; hasMore: boolean }>();
|
||||
const fetchPage = vi.fn((page: number) =>
|
||||
page === 1 ? Promise.resolve({ items: [1], hasMore: true }) : next.promise
|
||||
);
|
||||
const { result } = renderHook(() => usePagedList("k", fetchPage));
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
act(() => result.current.loadMore());
|
||||
act(() => result.current.loadMore());
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
await act(async () => {
|
||||
next.reject(new Error("offline"));
|
||||
await next.promise.catch(() => {});
|
||||
});
|
||||
expect(result.current.state).toMatchObject({
|
||||
data: { items: [1], page: 1, loadingMore: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("a cached list shows as it was, and carries on from its page", async () => {
|
||||
const fetchPage = vi.fn((page: number) =>
|
||||
Promise.resolve({ items: [page], hasMore: false })
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
usePagedList("k", fetchPage, {
|
||||
items: [1, 2],
|
||||
page: 2,
|
||||
hasMore: true,
|
||||
scrollY: 0,
|
||||
})
|
||||
);
|
||||
expect(result.current.state).toMatchObject({ data: { items: [1, 2] } });
|
||||
act(() => result.current.loadMore());
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toMatchObject({
|
||||
data: { items: [1, 2, 3] },
|
||||
})
|
||||
);
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ describe("useFetchState", () => {
|
||||
test("loads once enabled, and only once per key", async () => {
|
||||
const load = vi.fn(() => Promise.resolve(1));
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useFetchState("a", load, enabled),
|
||||
({ enabled }) => useFetchState("a", load, { enabled }),
|
||||
{ initialProps: { enabled: false } },
|
||||
);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
@@ -25,7 +25,7 @@ describe("useFetchState", () => {
|
||||
test("turning it off doesn't stop a load under way", async () => {
|
||||
const d = deferred<number>();
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useFetchState("a", () => d.promise, enabled),
|
||||
({ enabled }) => useFetchState("a", () => d.promise, { enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
rerender({ enabled: false });
|
||||
@@ -79,4 +79,18 @@ describe("useFetchState", () => {
|
||||
act(() => result.current.setData((xs) => [...xs, 2]));
|
||||
expect(result.current.state).toEqual({ status: "loaded", data: [1, 2] });
|
||||
});
|
||||
|
||||
test("an initial value starts loaded and loads nothing, for its key only", async () => {
|
||||
const load = vi.fn(() => Promise.resolve(2));
|
||||
const { result, rerender } = renderHook(
|
||||
({ key, initial }) => useFetchState(key, load, { initial }),
|
||||
{ initialProps: { key: "a", initial: 1 as number | undefined } },
|
||||
);
|
||||
expect(result.current.state).toEqual({ status: "loaded", data: 1 });
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
rerender({ key: "b", initial: undefined });
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toEqual({ status: "loaded", data: 2 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("not found", async () => {
|
||||
route("GET /api/playlists/:id", () => fail("nope", 404));
|
||||
route("GET /api/playlists/:id", () => fail("Playlist not found", 404));
|
||||
renderApp(<PlaylistDetail />, {
|
||||
route: "/playlists/x",
|
||||
path: "/playlists/:playlistId",
|
||||
@@ -212,15 +212,13 @@ describe("PlaylistDetail", () => {
|
||||
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("removing a dump fades it out, with an undo", async () => {
|
||||
test("a removal is sent once its fade ends, and an undo sends nothing", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
route("PUT /api/playlists/:pid/order", () => ({}));
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
const bravo = screen.getByText("Bravo").closest(
|
||||
@@ -229,33 +227,30 @@ describe("PlaylistDetail", () => {
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/mix/dumps/b",
|
||||
);
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Cancel removal" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
|
||||
dumpIds: ["a", "b", "c"],
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(screen.getByText("Bravo")).toBeInTheDocument();
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(0);
|
||||
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/p1/dumps/b",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused removal puts the dump back and says so", async () => {
|
||||
test("leaving the page during the fade still sends the removal", async () => {
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => fail("Forbidden", 403),
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
@@ -265,14 +260,36 @@ describe("PlaylistDetail", () => {
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"couldn't be removed",
|
||||
);
|
||||
expect(within(bravo).getByRole("button", { name: "Remove from playlist" }))
|
||||
.toBeInTheDocument();
|
||||
act(() => globalThis.dispatchEvent(new Event("pagehide")));
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("dragging reorders, and a refused reorder reloads the real order", async () => {
|
||||
test("a refused removal puts the dump back and says so", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => fail("Forbidden", 403),
|
||||
);
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
const bravo = screen.getByText("Bravo").closest(
|
||||
".playlist-dump-item",
|
||||
) as HTMLElement;
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"couldn't be removed",
|
||||
);
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("dragging reorders, and a refused reorder goes back to the server's order", async () => {
|
||||
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
|
||||
open();
|
||||
await screen.findByText("Charlie");
|
||||
@@ -292,12 +309,23 @@ describe("PlaylistDetail", () => {
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
|
||||
dumpIds: ["b", "c", "a"],
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/playlists/:id")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"order couldn't be saved",
|
||||
);
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
});
|
||||
|
||||
test("a dump that goes private leaves, and comes back at its place", async () => {
|
||||
const { socket } = open({ session: visitor });
|
||||
await screen.findByText("Bravo");
|
||||
socket.receive({ type: "dump_deleted", dumpId: "b" });
|
||||
expect(titles()).toEqual(["Alpha", "Charlie"]);
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "b", title: "Bravo" })),
|
||||
});
|
||||
expect(await screen.findByText("Bravo")).toBeInTheDocument();
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
});
|
||||
|
||||
test("deleting the playlist asks, then goes home", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
page,
|
||||
wire,
|
||||
@@ -316,9 +317,10 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("change password keeps you signed in with the fresh tokens", async () => {
|
||||
const freshToken = makeToken({ sub: "ann" });
|
||||
route(
|
||||
"POST /api/users/me/change-password",
|
||||
() => ({ token: "fresh-token", mediaToken: "fresh-media" }),
|
||||
() => ({ token: freshToken, mediaToken: "fresh-media" }),
|
||||
);
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
@@ -353,7 +355,7 @@ describe("UserPublicProfile", () => {
|
||||
expect(await within(dialog).findByText("Password changed successfully."))
|
||||
.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
"fresh-token",
|
||||
freshToken,
|
||||
);
|
||||
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
|
||||
currentPassword: "oldpassword",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { API_URL } from "../../../config/api.ts";
|
||||
import {
|
||||
fetchFirstPage,
|
||||
firstPage,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
readPage,
|
||||
restoredList,
|
||||
withFirst,
|
||||
} from "../../../utils/feed/pagedList.ts";
|
||||
@@ -10,10 +12,15 @@ import { calls, fail, route } from "../../fetch.ts";
|
||||
import { page } from "../../fixtures.ts";
|
||||
|
||||
describe("pagedList", () => {
|
||||
test("fetchFirstPage asks for page 1 and deserializes it", async () => {
|
||||
test("pageUrl adds the page to a query, or starts one", () => {
|
||||
expect(pageUrl("/a", 2, 5)).toBe("/a?page=2&limit=5");
|
||||
expect(pageUrl("/a?c=x", 1, 5)).toBe("/a?c=x&page=1&limit=5");
|
||||
});
|
||||
|
||||
test("readFirstPage deserializes the first page", async () => {
|
||||
route("GET /api/things", () => page([{ id: "a" }], true));
|
||||
const list = await fetchFirstPage(
|
||||
`${API_URL}/api/things`,
|
||||
const list = await readFirstPage(
|
||||
await fetch(pageUrl(`${API_URL}/api/things`, 1)),
|
||||
(w: { id: string }) => ({ ...w, seen: true }),
|
||||
);
|
||||
expect(list).toEqual({
|
||||
@@ -25,13 +32,13 @@ describe("pagedList", () => {
|
||||
expect(calls("GET /api/things")[0].url.searchParams.get("page")).toBe("1");
|
||||
});
|
||||
|
||||
test("fetchFirstPage makes a refused list empty, but not a lost one", async () => {
|
||||
test("a refused page is an empty first page, and an error elsewhere", async () => {
|
||||
route("GET /api/refused", () => fail("No", 403));
|
||||
expect(await fetchFirstPage(`${API_URL}/api/refused`, (w) => w))
|
||||
.toEqual(firstPage([], false));
|
||||
route("GET /api/lost", () => new TypeError("Failed to fetch"));
|
||||
await expect(fetchFirstPage(`${API_URL}/api/lost`, (w) => w)).rejects
|
||||
.toThrow(TypeError);
|
||||
expect(
|
||||
await readFirstPage(await fetch(`${API_URL}/api/refused`), (w) => w),
|
||||
).toEqual(firstPage([], false));
|
||||
await expect(readPage(await fetch(`${API_URL}/api/refused`), (w) => w))
|
||||
.rejects.toThrow("No");
|
||||
});
|
||||
|
||||
test("restoredList carries on from the cached page", () => {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
/**
|
||||
* A list of which the first pages are loaded, as the pages holding several
|
||||
* such lists keep each one.
|
||||
* Declares a list the API serves a page at a time, and reads its pages. Every
|
||||
* such list in the app is a {@link PagedList}, whose next page `useLoadMore`
|
||||
* appends.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import { dataOr } from "../shared/api.ts";
|
||||
import { dataOr, expectOk } from "../shared/api.ts";
|
||||
|
||||
/**
|
||||
* Held for each of its lists by `UserPlaylists` and the profile, whose lists
|
||||
* load together with the user and then a page at a time.
|
||||
* Held for a list by `usePagedList`, or by a page that loads several lists
|
||||
* with their user, as `UserPlaylists` and the profile do. `useLoadMore` loads
|
||||
* the pages after `page`.
|
||||
*/
|
||||
export interface PagedList<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
/** The last page loaded, from which loading more carries on. */
|
||||
/** Loading more carries on from this page, the last loaded. */
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
}
|
||||
@@ -33,23 +35,63 @@ export function restoredList<T>(
|
||||
return { items, hasMore, page, loadingMore: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `list` has a next page to load now, which `useLoadMore`
|
||||
* requires and `useInfiniteScroll` takes as `enabled`. A list not loaded yet
|
||||
* is null.
|
||||
*/
|
||||
export function canLoadMore(list: PagedList<unknown> | null): boolean {
|
||||
return !!list && list.hasMore && !list.loadingMore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a setter of a list's items as a setter of the list, for the sync
|
||||
* hooks, which edit items only.
|
||||
*/
|
||||
export function mapItems<T>(
|
||||
fn: (items: T[]) => T[],
|
||||
): (list: PagedList<T>) => PagedList<T> {
|
||||
return (list) => ({ ...list, items: fn(list.items) });
|
||||
}
|
||||
|
||||
/** Returns `url` asking for page `page`, `limit` items long. */
|
||||
export function pageUrl(
|
||||
url: string,
|
||||
page: number,
|
||||
limit = DEFAULT_PAGE_SIZE,
|
||||
): string {
|
||||
return `${url}${url.includes("?") ? "&" : "?"}page=${page}&limit=${limit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page `res` answers, its items deserialized.
|
||||
*
|
||||
* @throws {Error} With the server's message when it refuses, and when the
|
||||
* body isn't an envelope.
|
||||
*/
|
||||
export async function readPage<T>(
|
||||
res: Response,
|
||||
deserialize: (wire: Wire<T>) => T,
|
||||
): Promise<PaginatedData<T>> {
|
||||
const { items, hasMore } = await expectOk<PaginatedData<Wire<T>>>(res);
|
||||
return { items: items.map(deserialize), hasMore };
|
||||
}
|
||||
|
||||
const EMPTY_PAGE: PaginatedData<never> = { items: [], hasMore: false };
|
||||
|
||||
/**
|
||||
* Returns the first page of the list at `url`, which takes `page` and `limit`
|
||||
* in its query, or an empty list when the server refuses it. The profile pages
|
||||
* load their lists with it beside the user, whose failure alone fails the
|
||||
* page.
|
||||
* Returns the first page `res` answers as a list, or an empty list when the
|
||||
* server refuses it. The profile pages read their lists with it beside the
|
||||
* user, whose failure alone fails the page.
|
||||
*
|
||||
* @throws {Error} Offline, when aborted, and when the body isn't an envelope.
|
||||
* @throws {Error} When the body isn't an envelope.
|
||||
*/
|
||||
export async function fetchFirstPage<T>(
|
||||
url: string,
|
||||
export async function readFirstPage<T>(
|
||||
res: Response,
|
||||
deserialize: (wire: Wire<T>) => T,
|
||||
init?: RequestInit,
|
||||
): Promise<PagedList<T>> {
|
||||
const { items, hasMore } = await dataOr<PaginatedData<Wire<T>>>(
|
||||
await fetch(`${url}?page=1&limit=${DEFAULT_PAGE_SIZE}`, init),
|
||||
res,
|
||||
EMPTY_PAGE,
|
||||
);
|
||||
return firstPage(items.map(deserialize), hasMore);
|
||||
|
||||
@@ -49,9 +49,9 @@ export async function expectOk<T>(res: Response): Promise<T> {
|
||||
|
||||
/**
|
||||
* Returns the payload of `res`, or `fallback` when the response says
|
||||
* `success: false`. The profile pages read their side lists with it, as
|
||||
* `{ items: [], hasMore: false }` when refused, so that a list the server
|
||||
* refuses shows empty rather than failing the page.
|
||||
* `success: false`. `readFirstPage` reads the profile pages' lists with it, and
|
||||
* the profile its invitees, so that a list the server refuses shows empty
|
||||
* rather than failing the page.
|
||||
*
|
||||
* @throws {Error} When the body isn't JSON or isn't an envelope.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The state of something a component loads from the API, which `ListState`
|
||||
* renders while it isn't loaded.
|
||||
* Declares the state of something a component loads from the API, which
|
||||
* `ListState` renders while it isn't loaded.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const DUMP_KEY = "visited_dumps";
|
||||
const PLAYLIST_KEY = "visited_playlists";
|
||||
/** The age past which an item shows no unread dot, visited or not. */
|
||||
// Past this age, an item shows no unread dot, visited or not.
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Each key's visits, id to epoch ms, with the stored string they were parsed
|
||||
@@ -23,7 +23,7 @@ function load(key: string): Map<string, number> {
|
||||
try {
|
||||
raw = localStorage.getItem(key);
|
||||
} catch {
|
||||
// Blocked storage: whatever this page recorded.
|
||||
// With storage blocked, only what this page recorded counts.
|
||||
return cache.get(key)?.visits ?? new Map();
|
||||
}
|
||||
const cached = cache.get(key);
|
||||
@@ -36,7 +36,7 @@ function load(key: string): Map<string, number> {
|
||||
? new Map(parsed.map((id) => [id, Date.now()]))
|
||||
: new Map(Object.entries(parsed));
|
||||
} catch {
|
||||
// Unreadable: nothing visited.
|
||||
// An unreadable list counts as nothing visited.
|
||||
}
|
||||
cache.set(key, { raw, visits });
|
||||
return visits;
|
||||
|
||||
Reference in New Issue
Block a user