From 69d9bf899bf904199577987fe7cdbee62bd8b2bc Mon Sep 17 00:00:00 2001 From: khannurien Date: Tue, 29 Sep 2026 20:50:49 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20profile=20page=20is=20split=20by=20?= =?UTF-8?q?one=20rule,=20and=20what=20it=20re-invented=20is=20shared=20acr?= =?UTF-8?q?oss=20the=20app=20=E2=80=94=20UserPublicProfile=20was=201,949?= =?UTF-8?q?=20lines=20because=20it=20held=20its=20data,=20six=20tabs,=20se?= =?UTF-8?q?ven=20subcomponents=20and=20hand-rolled=20copies=20of=20things?= =?UTF-8?q?=20the=20app=20already=20had=20or=20repeated=20elsewhere.=20The?= =?UTF-8?q?=20page=20now=20only=20picks=20the=20tab=20and=20puts=20the=20p?= =?UTF-8?q?ieces=20together=20(253=20lines);=20useProfile=20holds=20the=20?= =?UTF-8?q?user=20and=20the=20three=20lists=20with=20their=20live=20sync,?= =?UTF-8?q?=20cache=20and=20scroll;=20each=20tab=20and=20header=20piece=20?= =?UTF-8?q?is=20one=20file=20under=20components/profile/;=20the=20followed?= =?UTF-8?q?=20and=20invitees=20tabs=20load=20on=20first=20open=20through?= =?UTF-8?q?=20the=20new=20useFetchState,=20keyed=20by=20username,=20which?= =?UTF-8?q?=20keeps=20them=20across=20tab=20switches.=20Every=20piece=20ta?= =?UTF-8?q?ken=20out=20replaces=20its=20copies:=20DumpCardList,=20which=20?= =?UTF-8?q?reads=20the=20votes=20from=20WSContext,=20is=20every=20list=20o?= =?UTF-8?q?f=20dump=20cards=20(the=20index=20feeds,=20followed,=20search,?= =?UTF-8?q?=20related=20dumps,=20UserDumps,=20UserUpvoted,=20the=20profile?= =?UTF-8?q?),=20so=20Index=20no=20longer=20threads=20vote=20props=20into?= =?UTF-8?q?=20the=20feeds;=20PlaylistCardList=20every=20list=20of=20playli?= =?UTF-8?q?st=20cards;=20FeedFooter=20the=20eight=20sentinel/loading-more/?= =?UTF-8?q?end=20blocks;=20ListState=20the=20loading/error/empty=20chains;?= =?UTF-8?q?=20Segmented=20the=20settings=20toggles,=20RoleSwitcher=20and?= =?UTF-8?q?=20the=20playlist=20edit=20form,=20with=20SegmentedField=20buil?= =?UTF-8?q?t=20on=20it;=20LoadMoreButton=20the=20three=20load-more=20butto?= =?UTF-8?q?ns;=20NewDumpButton=20UserDumps'=20and=20the=20profile's;=20use?= =?UTF-8?q?ScrollRestore=20four=20copies=20of=20the=20scroll=20restore,=20?= =?UTF-8?q?and=20the=20profile=20uses=20useScrollSave=20instead=20of=20sav?= =?UTF-8?q?ing=20its=20whole=20cache=20on=20every=20scroll.=20useFading=20?= =?UTF-8?q?takes=20an=20onFaded=20callback,=20which=20replaces=20PlaylistD?= =?UTF-8?q?etail's=20copy=20of=20its=20timers;=20useUpvotedList=20is=20the?= =?UTF-8?q?=20vote=20tracking=20UserUpvoted=20and=20the=20profile=20each?= =?UTF-8?q?=20had,=20and=20a=20withdrawn=20upvote=20now=20leaves=20the=20l?= =?UTF-8?q?ist=20and=20the=20cache=20once=20faded=20(it=20was=20only=20hid?= =?UTF-8?q?den,=20and=20came=20back=20after=20a=20tab=20switch).=20usePlay?= =?UTF-8?q?listMemberships=20is=20the=20membership=20load=20and=20toggle?= =?UTF-8?q?=20AddToPlaylistModal=20and=20DumpCreateModal=20each=20had.=20p?= =?UTF-8?q?agedList.ts=20(PagedList,=20firstPage,=20restoredList,=20fetchF?= =?UTF-8?q?irstPage,=20withFirst),=20fetchPublicUser=20and=20dataOr=20are?= =?UTF-8?q?=20shared=20by=20the=20profile,=20UserPlaylists=20and=20useUser?= =?UTF-8?q?DumpFeed;=20a=20refused=20side=20list=20still=20shows=20empty,?= =?UTF-8?q?=20as=20documented,=20and=20only=20the=20user=20fails=20the=20p?= =?UTF-8?q?age.=20FollowedFeed's=20two=20sections=20are=20one=20local=20ho?= =?UTF-8?q?ok,=20and=20its=20readPage=20is=20expectOk.=20A=20card=20is=20i?= =?UTF-8?q?ts=20owner's=20when=20the=20item=20is=20the=20signed-in=20user'?= =?UTF-8?q?s,=20everywhere:=20the=20profile's=20upvoted=20column=20marked?= =?UTF-8?q?=20every=20dump=20as=20the=20owner's=20on=20one's=20own=20profi?= =?UTF-8?q?le.=20No=20message=20changed;=20the=20catalogs=20only=20move=20?= =?UTF-8?q?their=20references.=20643=20frontend=20tests=20pass=20(11=20new?= =?UTF-8?q?=20for=20useFetchState,=20useFading's=20onFaded=20and=20pagedLi?= =?UTF-8?q?st),=20and=20tsc,=20eslint,=20doc:lint,=20deno=20check=20and=20?= =?UTF-8?q?deno=20fmt=20are=20clean.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/components/dump/DumpCardList.tsx | 51 + src/components/dump/NewDumpButton.tsx | 33 + .../dump/create/DumpCreateModal.tsx | 49 +- src/components/feed/DumpFeed.tsx | 115 +- src/components/feed/FeedFooter.tsx | 44 + src/components/feed/FollowedFeed.tsx | 461 ++-- src/components/feed/JournalFeed.tsx | 124 +- src/components/form/SegmentedField.tsx | 61 +- .../playlist/AddToPlaylistModal.tsx | 54 +- src/components/playlist/PlaylistCardList.tsx | 42 + src/components/profile/DescriptionEditor.tsx | 103 + src/components/profile/EmailEditor.tsx | 90 + src/components/profile/ProfileDescription.tsx | 91 + src/components/profile/ProfileDumpsTab.tsx | 71 + src/components/profile/ProfileFollowedTab.tsx | 119 ++ src/components/profile/ProfileHeader.tsx | 182 ++ src/components/profile/ProfileInviteesTab.tsx | 90 + src/components/profile/ProfileManageTab.tsx | 55 + src/components/profile/ProfileOwnActions.tsx | 119 ++ .../profile/ProfilePlaylistsTab.tsx | 57 + src/components/profile/ProfileSection.tsx | 48 + src/components/profile/ProfileSettingsTab.tsx | 128 ++ src/components/profile/RoleSwitcher.tsx | 95 + src/components/profile/SettingRow.tsx | 31 + src/components/shared/ListState.tsx | 46 + src/components/shared/LoadMoreButton.tsx | 35 + src/components/shared/Segmented.tsx | 51 + src/components/social/UserListPopover.tsx | 12 +- src/contexts/ws/WSContext.ts | 2 +- src/hooks/dump/useUpvotedList.ts | 142 ++ src/hooks/dump/useUserDumpFeed.ts | 103 +- src/hooks/feed/useFading.ts | 144 +- src/hooks/feed/useScrollSave.ts | 21 +- src/hooks/playlist/usePlaylistMemberships.ts | 84 + src/hooks/profile/useProfile.ts | 233 ++ src/hooks/shared/useFetchState.ts | 81 + src/locales/en.po | 426 ++-- src/locales/fr.po | 426 ++-- src/pages/Dump.tsx | 24 +- src/pages/Index.tsx | 75 +- src/pages/Notifications.tsx | 12 +- src/pages/PlaylistDetail.tsx | 140 +- src/pages/Search.tsx | 50 +- src/pages/UserDumps.tsx | 56 +- src/pages/UserPlaylists.tsx | 248 +-- src/pages/UserPublicProfile.tsx | 1894 +---------------- src/pages/UserUpvoted.tsx | 174 +- src/test/hooks/feed/useFading.test.tsx | 13 + src/test/hooks/shared/useFetchState.test.tsx | 82 + src/test/utils/feed/pagedList.test.ts | 53 + src/utils/feed/pagedList.ts | 64 + src/utils/shared/api.ts | 13 + src/utils/shared/fetchPublicUser.ts | 28 + src/utils/shared/loadState.ts | 16 + 54 files changed, 3460 insertions(+), 3601 deletions(-) create mode 100644 src/components/dump/DumpCardList.tsx create mode 100644 src/components/dump/NewDumpButton.tsx create mode 100644 src/components/feed/FeedFooter.tsx create mode 100644 src/components/playlist/PlaylistCardList.tsx create mode 100644 src/components/profile/DescriptionEditor.tsx create mode 100644 src/components/profile/EmailEditor.tsx create mode 100644 src/components/profile/ProfileDescription.tsx create mode 100644 src/components/profile/ProfileDumpsTab.tsx create mode 100644 src/components/profile/ProfileFollowedTab.tsx create mode 100644 src/components/profile/ProfileHeader.tsx create mode 100644 src/components/profile/ProfileInviteesTab.tsx create mode 100644 src/components/profile/ProfileManageTab.tsx create mode 100644 src/components/profile/ProfileOwnActions.tsx create mode 100644 src/components/profile/ProfilePlaylistsTab.tsx create mode 100644 src/components/profile/ProfileSection.tsx create mode 100644 src/components/profile/ProfileSettingsTab.tsx create mode 100644 src/components/profile/RoleSwitcher.tsx create mode 100644 src/components/profile/SettingRow.tsx create mode 100644 src/components/shared/ListState.tsx create mode 100644 src/components/shared/LoadMoreButton.tsx create mode 100644 src/components/shared/Segmented.tsx create mode 100644 src/hooks/dump/useUpvotedList.ts create mode 100644 src/hooks/playlist/usePlaylistMemberships.ts create mode 100644 src/hooks/profile/useProfile.ts create mode 100644 src/hooks/shared/useFetchState.ts create mode 100644 src/test/hooks/shared/useFetchState.test.tsx create mode 100644 src/test/utils/feed/pagedList.test.ts create mode 100644 src/utils/feed/pagedList.ts create mode 100644 src/utils/shared/fetchPublicUser.ts create mode 100644 src/utils/shared/loadState.ts diff --git a/src/components/dump/DumpCardList.tsx b/src/components/dump/DumpCardList.tsx new file mode 100644 index 00000000..ea275c5e --- /dev/null +++ b/src/components/dump/DumpCardList.tsx @@ -0,0 +1,51 @@ +/** + * Renders a list of dumps as cards, wired to the live votes. + * + * @module + */ + +import type { Dump } from "../../../shared/dump.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +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. + */ +export interface DumpCardListProps { + dumps: Dump[]; + /** A class for one card, as `fadeClassName` gives a fading one. */ + cardClassName?: (dump: Dump) => string | undefined; +} + +/** + * 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. + */ +export function DumpCardList( + { dumps, cardClassName }: DumpCardListProps, +): React.JSX.Element { + const { user } = useAuth(); + const { voteCounts, myVotes, castVote, removeVote } = useWS(); + return ( + + ); +} diff --git a/src/components/dump/NewDumpButton.tsx b/src/components/dump/NewDumpButton.tsx new file mode 100644 index 00000000..29a4fd12 --- /dev/null +++ b/src/components/dump/NewDumpButton.tsx @@ -0,0 +1,33 @@ +/** + * Renders the button that opens the dump creation modal, on the user's own + * profile and dumps pages. + * + * @module + */ + +import { useState } from "react"; +import { Trans } from "@lingui/react/macro"; +import { DumpCreateModal } from "./create/DumpCreateModal.tsx"; + +/** + * Renders a "+ New dump" button, as `NewPlaylistButton` does for playlists, + * that opens {@link DumpCreateModal}. The modal hands the new dump to + * `WSContext` itself, so the button takes no callback. + */ +export function NewDumpButton(): React.JSX.Element { + const [open, setOpen] = useState(false); + return ( + <> + + {open && setOpen(false)} />} + + ); +} diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx index c122ce5c..515b23fb 100644 --- a/src/components/dump/create/DumpCreateModal.tsx +++ b/src/components/dump/create/DumpCreateModal.tsx @@ -22,15 +22,14 @@ import type { LinkPreview, RichContent, } from "../../../../shared/dump.ts"; -import type { PlaylistMembership } from "../../../../shared/playlist.ts"; import type { Wire } from "../../../../shared/api.ts"; import { deserializeDump, deserializeDumpUrlMatch, - deserializePlaylistMembership, } from "../../../utils/shared/deserialize.ts"; import { useAuth } from "../../../hooks/auth/useAuth.ts"; import { useWS } from "../../../hooks/ws/useWS.ts"; +import { usePlaylistMemberships } from "../../../hooks/playlist/usePlaylistMemberships.ts"; import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts"; import { formatBytes, @@ -183,9 +182,6 @@ export function DumpCreateModal( return next; }); - const [memberships, setMemberships] = useState([]); - const [playlistsLoading, setPlaylistsLoading] = useState(false); - const selectFile = useCallback((f: File | null) => { setValue("file", f); if (f) { @@ -442,41 +438,9 @@ export function DumpCreateModal( injectDump(dump); setCreatedDump(dump); goToStep("playlist", "forward"); - setPlaylistsLoading(true); - authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`) - .then((r) => r.json()) - .then((body) => { - if (body.success) { - setMemberships( - (body.data as Wire[]).map( - deserializePlaylistMembership, - ), - ); - } - }) - .catch(() => {}) - .finally(() => setPlaylistsLoading(false)); }); - const toggleMembership = async (membership: PlaylistMembership) => { - if (!createdDump) return; - const { playlist, hasDump } = membership; - // Not optimistic: the box changes only once the server agrees. - try { - const res = await authFetch( - `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, - { method: hasDump ? "DELETE" : "POST" }, - ); - if (!res.ok) return; - } catch { - return; - } - setMemberships((prev) => - prev.map((m) => - m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m - ) - ); - }; + const playlists = usePlaylistMemberships(createdDump?.id ?? null); const canProceed = !!file || !!canonicalUrl; @@ -565,11 +529,10 @@ export function DumpCreateModal( - setMemberships((prev) => [membership, ...prev])} + memberships={playlists.memberships} + loading={playlists.loading} + onToggle={playlists.toggle} + onPlaylistCreated={playlists.add} />
diff --git a/src/components/feed/DumpFeed.tsx b/src/components/feed/DumpFeed.tsx index a708e7bf..a2bc2100 100644 --- a/src/components/feed/DumpFeed.tsx +++ b/src/components/feed/DumpFeed.tsx @@ -6,35 +6,36 @@ */ import { type Ref, useMemo } from "react"; -import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { DumpCard } from "../dump/DumpCard.tsx"; -import { ErrorCard } from "../shared/ErrorCard.tsx"; +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 { SelfUser } from "../../../shared/user.ts"; +import type { LoadState } from "../../utils/shared/loadState.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. + */ +export type DumpFeedState = LoadState<{ + dumps: Dump[]; + hasMore: boolean; + page: number; + loadingMore: boolean; +}>; + /** * 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`. */ + state: DumpFeedState; + /** The loaded dumps, with those `WSContext` added since and less deleted. */ dumps: Dump[]; - loading: boolean; - error: string | null; - hasMore: boolean; - loadingMore: boolean; /** From `useInfiniteScroll`. The feed places it after the last card. */ sentinelRef: Ref; - /** - * Counts pushed by the socket. A dump missing here shows its fetched - * `voteCount`. - */ - voteCounts: Record; - myVotes: Set; - user: SelfUser | null; - castVote: (id: string, shownCount?: number) => void; - removeVote: (id: string, shownCount?: number) => void; } /** @@ -46,25 +47,12 @@ export interface DumpFeedProps extends MainFeedProps { } /** - * Renders the "hot" and "new" tabs as a list of {@link DumpCard}, sorted on the - * client, followed by the infinite-scroll sentinel. It also renders the - * loading, error and empty states. `Index` is its only user. + * Renders the "hot" and "new" tabs as a {@link DumpCardList}, sorted on the + * client, followed by the infinite-scroll sentinel, or the loading, error or + * empty state. `Index` is its only user. */ export function DumpFeed( - { - order, - dumps, - loading, - error, - hasMore, - loadingMore, - sentinelRef, - voteCounts, - myVotes, - user, - castVote, - removeVote, - }: DumpFeedProps, + { order, state, dumps, sentinelRef }: DumpFeedProps, ): React.JSX.Element { // The server pages by date, so "hot" only ranks the pages loaded so far. const sorted = useMemo( @@ -77,49 +65,26 @@ export function DumpFeed( [dumps, order], ); - if (loading) { - return ( -

- Loading… -

- ); - } - if (error) return ; - if (sorted.length === 0) { - return ( -

- No dumps yet. Be the first! -

- ); - } - return ( - <> -
    - {sorted.map((dump) => ( - sorted.length === 0} + empty={ +

    + No dumps yet. Be the first! +

    + } + > + {({ hasMore, loadingMore }) => ( + <> + + - ))} -
-
- {loadingMore && ( -

- Loading more… -

+ )} - {!hasMore && sorted.length > 0 && ( -

- You've reached the end. -

- )} - + ); } diff --git a/src/components/feed/FeedFooter.tsx b/src/components/feed/FeedFooter.tsx new file mode 100644 index 00000000..fc8f03f0 --- /dev/null +++ b/src/components/feed/FeedFooter.tsx @@ -0,0 +1,44 @@ +/** + * Renders what follows the last item of an infinitely scrolled list. + * + * @module + */ + +import type { Ref } from "react"; +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. + */ +export interface FeedFooterProps { + hasMore: boolean; + loadingMore: boolean; + /** From `useInfiniteScroll`. */ + sentinelRef: Ref; +} + +/** + * Renders the sentinel while there is more to load, "Loading more…" while it + * loads, and "You've reached the end." once there is no more. The list renders + * it after a non-empty list only, since an empty list has its own message. + */ +export function FeedFooter( + { hasMore, loadingMore, sentinelRef }: FeedFooterProps, +): React.JSX.Element { + return ( + <> + {hasMore &&
} + {loadingMore && ( +

+ Loading more… +

+ )} + {!hasMore && ( +

+ You've reached the end. +

+ )} + + ); +} diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index 0c10aa94..7cd597df 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -9,8 +9,10 @@ import { useCallback, useEffect, useState } from "react"; import { TabBar } from "../shared/TabBar.tsx"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { DumpCard } from "../dump/DumpCard.tsx"; -import { ErrorCard } from "../shared/ErrorCard.tsx"; +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 { useAuth } from "../../hooks/auth/useAuth.ts"; import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts"; @@ -23,335 +25,170 @@ import { } from "../../utils/shared/deserialize.ts"; import type { Dump } from "../../../shared/dump.ts"; import type { PaginatedData, Wire } from "../../../shared/api.ts"; -import type { SelfUser } from "../../../shared/user.ts"; -import { friendlyFetchError } from "../../utils/shared/api.ts"; - -type FeedState = - | { status: "loading" } - | { status: "error"; error: string } - | { - status: "loaded"; - dumps: Dump[]; - hasMore: boolean; - page: number; - loadingMore: boolean; - }; - -/** - * Returns the page of dumps in the body of `res`. - * - * @throws {Error} The server's own message on an error response, so that - * `friendlyFetchError` doesn't report it as a lost connection. - */ -async function readPage(res: Response): Promise>> { - const body = await res.json(); - if (!body.success) { - throw new Error(body.error?.message ?? `HTTP ${res.status}`); - } - return body.data as PaginatedData>; -} +import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; type FollowedSection = "users" | "playlists"; /** - * Passed to {@link FollowedFeed} by `Index`, with the vote state of - * `WSContext`. Unlike the other tabs, it loads its own dumps. + * Returns one section's feed, from `GET /api/follows/feed/
`, and the + * call that loads its next page. A section cached like the index's feed starts + * loaded and isn't fetched. */ -export interface FollowedFeedProps { - /** Scopes both sections to a category. */ - categorySlug?: string; - voteCounts: Record; - myVotes: Set; - /** `Index` renders the tab for a signed-in user only. */ - user: SelfUser; - castVote: (id: string, shownCount?: number) => void; - removeVote: (id: string, shownCount?: number) => void; +function useFollowedSection( + section: FollowedSection, + categorySlug: string | undefined, +): { state: DumpFeedState; loadMore: () => void } { + const { token } = useAuth(); + const catQuery = categorySlug + ? `&category=${encodeURIComponent(categorySlug)}` + : ""; + + const { cached, saveState } = useFeedCache( + `feed:followed-${section}:${categorySlug ?? "all"}`, + hydrateDump, + ); + const [state, setState] = useState(() => + cached + ? { + status: "loaded", + dumps: cached.items, + hasMore: cached.hasMore, + page: cached.page, + loadingMore: false, + } + : { status: "loading" } + ); + + const setItems = useCallback( + (fn: (prev: Dump[]) => Dump[]) => + setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }), + [], + ); + useDumpListSync(setItems); + + useEffect(() => { + if (!token || state.status !== "loading") return; + fetch( + `${API_URL}/api/follows/feed/${section}?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, + { headers: { Authorization: `Bearer ${token}` } }, + ) + .then(expectOk>>) + .then(({ items, hasMore }) => + setState({ + status: "loaded", + dumps: items.map(deserializeDump), + hasMore, + page: 1, + loadingMore: false, + }) + ) + .catch((err) => + setState({ status: "error", error: friendlyFetchError(err) }) + ); + }, [token, state.status, section, catQuery]); + + // Only the list is kept here: `Index` saves and restores the page's scroll + // for every tab, this one included. + useScrollSave( + state.status === "loaded", + useCallback(() => { + if (state.status !== "loaded") return; + saveState(state.dumps, state.page, state.hasMore, 0); + }, [state, saveState]), + ); + + const loadMore = useCallback(() => { + if ( + state.status !== "loaded" || !state.hasMore || state.loadingMore || + !token + ) return; + const nextPage = state.page + 1; + setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); + fetch( + `${API_URL}/api/follows/feed/${section}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, + { headers: { Authorization: `Bearer ${token}` } }, + ) + .then(expectOk>>) + .then(({ items, hasMore }) => + setState((s) => + s.status === "loaded" + ? { + ...s, + dumps: [...s.dumps, ...items.map(deserializeDump)], + hasMore, + page: nextPage, + loadingMore: false, + } + : s + ) + ) + .catch(() => + setState((s) => + s.status === "loaded" ? { ...s, loadingMore: false } : s + ) + ); + }, [state, token, section, catQuery]); + + return { state, loadMore }; } interface FollowedSubFeedProps { - state: FeedState; - voteCounts: Record; - myVotes: Set; - user: SelfUser; - castVote: (id: string, shownCount?: number) => void; - removeVote: (id: string, shownCount?: number) => void; + state: DumpFeedState; emptyMessage: string; onLoadMore: () => void; } /** Renders one section's list, with its own infinite-scroll sentinel. */ -function FollowedSubFeed({ - state, - voteCounts, - myVotes, - user, - castVote, - removeVote, - emptyMessage, - onLoadMore, -}: FollowedSubFeedProps) { - const enabled = state.status === "loaded" && state.hasMore && - !state.loadingMore; - const sentinelRef = useInfiniteScroll(onLoadMore, enabled); - - if (state.status === "loading") { - return ( -

- Loading… -

- ); - } - if (state.status === "error") { - return ; - } - - if (state.dumps.length === 0) { - return

{emptyMessage}

; - } +function FollowedSubFeed( + { state, emptyMessage, onLoadMore }: FollowedSubFeedProps, +) { + const sentinelRef = useInfiniteScroll( + onLoadMore, + state.status === "loaded" && state.hasMore && !state.loadingMore, + ); return ( - <> -
    - {state.dumps.map((dump) => ( - dumps.length === 0} + empty={

    {emptyMessage}

    } + > + {({ dumps, hasMore, loadingMore }) => ( + <> + + - ))} -
- {state.hasMore &&
} - {state.loadingMore && ( -

- Loading more… -

+ )} - {!state.hasMore && state.dumps.length > 0 && ( -

- You've reached the end. -

- )} - + ); } +/** + * Passed to {@link FollowedFeed} by `Index`, which renders the tab for a + * signed-in user only. Unlike the other tabs, it loads its own dumps. + */ +export interface FollowedFeedProps { + /** Scopes both sections to a category. */ + categorySlug?: string; +} + /** * Renders the "followed" tab in two sections, "from people" and "from * playlists", from `GET /api/follows/feed/users` and * `GET /api/follows/feed/playlists`. Both load at once, and each keeps its own * pages, cached like the index's feed. */ -export function FollowedFeed({ - categorySlug, - voteCounts, - myVotes, - user, - castVote, - removeVote, -}: FollowedFeedProps): React.JSX.Element { - const { token } = useAuth(); - - const scope = categorySlug ?? "all"; - const catQuery = categorySlug - ? `&category=${encodeURIComponent(categorySlug)}` - : ""; - - const { cached: cachedUsers, saveState: saveUsers } = useFeedCache( - `feed:followed-users:${scope}`, - hydrateDump, - ); - const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< - Dump - >( - `feed:followed-playlists:${scope}`, - hydrateDump, - ); - - const [usersState, setUsersState] = useState(() => - cachedUsers - ? { - status: "loaded", - dumps: cachedUsers.items, - hasMore: cachedUsers.hasMore, - page: cachedUsers.page, - loadingMore: false, - } - : { status: "loading" } - ); - - const [playlistsState, setPlaylistsState] = useState(() => - cachedPlaylists - ? { - status: "loaded", - dumps: cachedPlaylists.items, - hasMore: cachedPlaylists.hasMore, - page: cachedPlaylists.page, - loadingMore: false, - } - : { status: "loading" } - ); - +export function FollowedFeed( + { categorySlug }: FollowedFeedProps, +): React.JSX.Element { + const users = useFollowedSection("users", categorySlug); + const playlists = useFollowedSection("playlists", categorySlug); const [section, setSection] = useState("users"); - const setUsersItems = useCallback( - (fn: (prev: Dump[]) => Dump[]) => - setUsersState((s) => - s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) } - ), - [], - ); - useDumpListSync(setUsersItems); - - const setPlaylistsItems = useCallback( - (fn: (prev: Dump[]) => Dump[]) => - setPlaylistsState((s) => - s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) } - ), - [], - ); - useDumpListSync(setPlaylistsItems); - - // A cached section starts loaded, so only the others are fetched. - useEffect(() => { - if (!token) return; - if (usersState.status === "loading") { - fetch( - `${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, - { headers: { Authorization: `Bearer ${token}` } }, - ) - .then(readPage) - .then(({ items, hasMore }) => { - setUsersState({ - status: "loaded", - dumps: items.map(deserializeDump), - hasMore, - page: 1, - loadingMore: false, - }); - }) - .catch((err) => - setUsersState({ status: "error", error: friendlyFetchError(err) }) - ); - } - if (playlistsState.status === "loading") { - fetch( - `${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, - { headers: { Authorization: `Bearer ${token}` } }, - ) - .then(readPage) - .then(({ items, hasMore }) => { - setPlaylistsState({ - status: "loaded", - dumps: items.map(deserializeDump), - hasMore, - page: 1, - loadingMore: false, - }); - }) - .catch((err) => - setPlaylistsState({ - status: "error", - error: friendlyFetchError(err), - }) - ); - } - }, [token, usersState.status, playlistsState.status, catQuery]); - - // Only the lists are kept here: `Index` saves and restores the page's scroll - // for every tab, this one included. - useScrollSave( - usersState.status === "loaded", - useCallback(() => { - if (usersState.status !== "loaded") return; - saveUsers(usersState.dumps, usersState.page, usersState.hasMore, 0); - }, [usersState, saveUsers]), - ); - useScrollSave( - playlistsState.status === "loaded", - useCallback(() => { - if (playlistsState.status !== "loaded") return; - savePlaylists( - playlistsState.dumps, - playlistsState.page, - playlistsState.hasMore, - 0, - ); - }, [playlistsState, savePlaylists]), - ); - - const loadMoreUsers = useCallback(() => { - if ( - usersState.status !== "loaded" || !usersState.hasMore || - usersState.loadingMore || !token - ) return; - const nextPage = usersState.page + 1; - setUsersState((s) => - s.status === "loaded" ? { ...s, loadingMore: true } : s - ); - fetch( - `${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, - { headers: { Authorization: `Bearer ${token}` } }, - ) - .then(readPage) - .then(({ items, hasMore }) => { - setUsersState((s) => - s.status === "loaded" - ? { - ...s, - dumps: [...s.dumps, ...items.map(deserializeDump)], - hasMore, - page: nextPage, - loadingMore: false, - } - : s - ); - }) - .catch(() => - setUsersState((s) => - s.status === "loaded" ? { ...s, loadingMore: false } : s - ) - ); - }, [usersState, token, catQuery]); - - const loadMorePlaylists = useCallback(() => { - if ( - playlistsState.status !== "loaded" || !playlistsState.hasMore || - playlistsState.loadingMore || !token - ) return; - const nextPage = playlistsState.page + 1; - setPlaylistsState((s) => - s.status === "loaded" ? { ...s, loadingMore: true } : s - ); - fetch( - `${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, - { headers: { Authorization: `Bearer ${token}` } }, - ) - .then(readPage) - .then(({ items, hasMore }) => { - setPlaylistsState((s) => - s.status === "loaded" - ? { - ...s, - dumps: [...s.dumps, ...items.map(deserializeDump)], - hasMore, - page: nextPage, - loadingMore: false, - } - : s - ); - }) - .catch(() => - setPlaylistsState((s) => - s.status === "loaded" ? { ...s, loadingMore: false } : s - ) - ); - }, [playlistsState, token, catQuery]); - return (
)} {section === "playlists" && ( )}
diff --git a/src/components/feed/JournalFeed.tsx b/src/components/feed/JournalFeed.tsx index 38f5f076..3a013098 100644 --- a/src/components/feed/JournalFeed.tsx +++ b/src/components/feed/JournalFeed.tsx @@ -6,91 +6,73 @@ */ import { Fragment, useMemo } from "react"; -import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { ErrorCard } from "../shared/ErrorCard.tsx"; +import { ListState } from "../shared/ListState.tsx"; import { JournalCard } from "../dump/JournalCard.tsx"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useWS } from "../../hooks/ws/useWS.ts"; +import { FeedFooter } from "./FeedFooter.tsx"; import { composeJournal } from "../../utils/feed/journalLayout.ts"; import type { MainFeedProps } from "./DumpFeed.tsx"; /** * Renders the feed `Index` passes as blocks of {@link JournalCard}, laid out - * by `composeJournal`, followed by the infinite-scroll sentinel. It also - * renders the loading, error and empty states. + * by `composeJournal`, followed by the infinite-scroll sentinel, or the + * loading, error or empty state. The votes come from `WSContext`. */ export function JournalFeed( - { - dumps, - loading, - error, - hasMore, - loadingMore, - sentinelRef, - voteCounts, - myVotes, - user, - castVote, - removeVote, - }: MainFeedProps, + { state, dumps, sentinelRef }: MainFeedProps, ): React.JSX.Element { + const { user } = useAuth(); + const { voteCounts, myVotes, castVote, removeVote } = useWS(); const blocks = useMemo(() => composeJournal(dumps), [dumps]); - if (loading) { - return ( -

- Loading… -

- ); - } - if (error) return ; - if (blocks.length === 0) { - return ( -

- No dumps yet. Be the first! -

- ); - } - return ( - <> - { - /* Each block is its own grid, with a themable
between them. Inside - a grid the
would be stretched to a 9rem row, while out here a - theme can shrink it to nothing. */ + blocks.length === 0} + empty={ +

+ No dumps yet. Be the first! +

} -
- {blocks.map((block, index) => ( - - {index > 0 &&
} -
    - {block.entries.map(({ dump, shape }) => ( - - ))} -
-
- ))} -
-
- {loadingMore && ( -

- Loading more… -

+ > + {({ hasMore, loadingMore }) => ( + <> + { + /* Each block is its own grid, with a themable
between them. + Inside a grid the
would be stretched to a 9rem row, while out + here a theme can shrink it to nothing. */ + } +
+ {blocks.map((block, index) => ( + + {index > 0 &&
} +
    + {block.entries.map(({ dump, shape }) => ( + + ))} +
+
+ ))} +
+ + )} - {!hasMore && blocks.length > 0 && ( -

- You've reached the end. -

- )} - + ); } diff --git a/src/components/form/SegmentedField.tsx b/src/components/form/SegmentedField.tsx index f162c1fa..4296a8e7 100644 --- a/src/components/form/SegmentedField.tsx +++ b/src/components/form/SegmentedField.tsx @@ -5,8 +5,12 @@ * @module */ -import type { ReactNode } from "react"; import { Trans } from "@lingui/react/macro"; +import { + Segmented, + type SegmentedOption, + type SegmentedProps, +} from "../shared/Segmented.tsx"; import { Controller, type FieldValues, @@ -14,15 +18,6 @@ import { useFormContext, } from "react-hook-form"; -/** - * Passed in {@link SegmentedFieldProps.options}. Its `value` is compared to the - * field's with `===` and must be unique as a string, since it keys the button. - */ -export interface SegmentedOption { - value: V; - label: ReactNode; -} - /** * Passed to {@link SegmentedField} by a form, or by {@link VisibilityToggle}. */ @@ -34,9 +29,8 @@ export interface SegmentedFieldProps { } /** - * Renders the options as buttons, the one equal to the field's value marked - * active, in the `.visibility-toggle` style. It must sit under a - * react-hook-form `FormProvider`. + * Renders a {@link Segmented} bound to the field `name`, under its label. It + * must sit under a react-hook-form `FormProvider`. */ export function SegmentedField({ name, @@ -53,25 +47,24 @@ export function SegmentedField({ name={name} control={control} render={({ field }) => ( -
- {options.map((opt) => ( - - ))} -
+ )} />
); } +/** The Public/Private options, true for public. */ +const VISIBILITY_OPTIONS: SegmentedOption[] = [ + { value: true, label: Public }, + { value: false, label: Private }, +]; + /** Passed to {@link VisibilityToggle} by a dump or playlist form. */ export interface VisibilityToggleProps { /** A boolean field, true for public. */ @@ -95,10 +88,18 @@ export function VisibilityToggle({ name={name} label={label} disabled={disabled} - options={[ - { value: true, label: Public }, - { value: false, label: Private }, - ]} + options={VISIBILITY_OPTIONS} /> ); } + +/** + * Renders a Public/Private {@link Segmented} outside `SegmentedField`, for + * `PlaylistDetail`'s edit form, which binds it with its own `Controller` and + * styles it apart. + */ +export function VisibilitySegmented( + props: Omit, "options">, +): React.JSX.Element { + return ; +} diff --git a/src/components/playlist/AddToPlaylistModal.tsx b/src/components/playlist/AddToPlaylistModal.tsx index d4334262..70de77d5 100644 --- a/src/components/playlist/AddToPlaylistModal.tsx +++ b/src/components/playlist/AddToPlaylistModal.tsx @@ -5,13 +5,8 @@ * @module */ -import { useEffect, useState } from "react"; import { t } from "@lingui/core/macro"; -import { API_URL } from "../../config/api.ts"; -import { useAuth } from "../../hooks/auth/useAuth.ts"; -import type { PlaylistMembership } from "../../../shared/playlist.ts"; -import type { Wire } from "../../../shared/api.ts"; -import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts"; +import { usePlaylistMemberships } from "../../hooks/playlist/usePlaylistMemberships.ts"; import { Modal } from "../shared/Modal.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; @@ -23,50 +18,12 @@ export interface AddToPlaylistModalProps { /** * Renders a {@link PlaylistMembershipPanel} in a modal, for the playlists - * `GET /api/playlists/by-dump/:dumpId/memberships` answers. A toggle adds or - * removes the dump on the server and shows only once the server agrees. + * `usePlaylistMemberships` loads and toggles. */ export function AddToPlaylistModal( { dumpId, onClose }: AddToPlaylistModalProps, ): React.JSX.Element { - const { authFetch } = useAuth(); - const [memberships, setMemberships] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`) - .then((r) => r.json()) - .then((body) => { - if (body.success) { - setMemberships( - (body.data as Wire[]).map( - deserializePlaylistMembership, - ), - ); - } - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [dumpId, authFetch]); - - const toggleMembership = async (membership: PlaylistMembership) => { - const { playlist, hasDump } = membership; - // A refusal or a dropped connection leaves the box showing the truth. - try { - const res = await authFetch( - `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, - { method: hasDump ? "DELETE" : "POST" }, - ); - if (!res.ok) return; - } catch { - return; - } - setMemberships((prev) => - prev.map((m) => - m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m - ) - ); - }; + const { memberships, loading, toggle, add } = usePlaylistMemberships(dumpId); return ( @@ -74,9 +31,8 @@ export function AddToPlaylistModal( dumpId={dumpId} memberships={memberships} loading={loading} - onToggle={toggleMembership} - onPlaylistCreated={(membership) => - setMemberships((prev) => [membership, ...prev])} + onToggle={toggle} + onPlaylistCreated={add} /> ); diff --git a/src/components/playlist/PlaylistCardList.tsx b/src/components/playlist/PlaylistCardList.tsx new file mode 100644 index 00000000..4b0adb94 --- /dev/null +++ b/src/components/playlist/PlaylistCardList.tsx @@ -0,0 +1,42 @@ +/** + * Renders a list of playlists as cards. + * + * @module + */ + +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. + */ +export interface PlaylistCardListProps { + playlists: Playlist[]; + /** Given, each card shows a delete button, which calls it. */ + onDelete?: (playlist: Playlist) => void; +} + +/** + * Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being + * its owner's when the playlist is the signed-in user's. + */ +export function PlaylistCardList( + { playlists, onDelete }: PlaylistCardListProps, +): React.JSX.Element { + const { user } = useAuth(); + return ( +
    + {playlists.map((p) => ( + onDelete(p))} + /> + ))} +
+ ); +} diff --git a/src/components/profile/DescriptionEditor.tsx b/src/components/profile/DescriptionEditor.tsx new file mode 100644 index 00000000..4f82ffd3 --- /dev/null +++ b/src/components/profile/DescriptionEditor.tsx @@ -0,0 +1,103 @@ +/** + * Renders the inline editor of the signed-in user's description, under their + * profile's header. + * + * @module + */ + +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 { PublicUser, UpdateUserRequest } from "../../../shared/user.ts"; +import { expectOk } from "../../utils/shared/api.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useApiForm } from "../../hooks/form/useApiForm.ts"; +import { TextEditor } from "../editor/TextEditor.tsx"; +import { FormError } from "../form/FormError.tsx"; +import { SubmitButton } from "../form/SubmitButton.tsx"; + +/** + * Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it + * in place of the description while editing. + */ +export interface DescriptionEditorProps { + initialDescription: string; + onCancel: () => void; + onSaved: (description: string | undefined) => void; +} + +/** + * Renders the description's Markdown editor, saved to `PATCH /api/users/me`. + * Other browsers see the change through the socket's `user_updated` event. + */ +export function DescriptionEditor( + { initialDescription, onCancel, onSaved }: DescriptionEditorProps, +): React.JSX.Element { + const { authFetch } = useAuth(); + const form = useApiForm<{ description: string }>({ + defaultValues: { description: initialDescription }, + }); + const description = form.watch("description"); + const submitting = form.formState.isSubmitting; + + const onSubmit = form.submit(async ({ description }) => { + const trimmed = description.trim(); + // Sent even when empty: left out, the server would keep the old one. + await expectOk>( + await authFetch(`${API_URL}/api/users/me`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + { description: trimmed } satisfies UpdateUserRequest, + ), + }), + ); + onSaved(trimmed || undefined); + }); + + return ( + +
+ ( + void onSubmit()} + onCancel={submitting ? undefined : onCancel} + placeholder={t`Who am I?`} + autoResize + maxLength={VALIDATION.USER_DESCRIPTION_MAX} + /> + )} + /> +
+ Saving…} + disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX} + > + Save + + + +
+ +
+ ); +} diff --git a/src/components/profile/EmailEditor.tsx b/src/components/profile/EmailEditor.tsx new file mode 100644 index 00000000..db4a4dca --- /dev/null +++ b/src/components/profile/EmailEditor.tsx @@ -0,0 +1,90 @@ +/** + * Renders the inline editor of the signed-in user's email, in their profile's + * header. + * + * @module + */ + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { FormProvider } from "react-hook-form"; +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts"; +import { expectOk } from "../../utils/shared/api.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useApiForm } from "../../hooks/form/useApiForm.ts"; +import { FormError } from "../form/FormError.tsx"; +import { SubmitButton } from "../form/SubmitButton.tsx"; + +/** + * Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of + * the email while editing. + */ +export interface EmailEditorProps { + initialEmail: string; + onCancel: () => void; + onSaved: (email: string) => void; +} + +/** + * Renders the email field, saved to `PATCH /api/users/me`. Escape cancels. + */ +export function EmailEditor( + { initialEmail, onCancel, onSaved }: EmailEditorProps, +): React.JSX.Element { + const { authFetch } = useAuth(); + const form = useApiForm<{ email: string }>({ + defaultValues: { email: initialEmail }, + }); + const email = form.watch("email"); + const submitting = form.formState.isSubmitting; + + const onSubmit = form.submit(async ({ email }) => { + await expectOk>( + await authFetch(`${API_URL}/api/users/me`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + { email: email.trim() } satisfies UpdateUserRequest, + ), + }), + ); + onSaved(email.trim()); + }); + + return ( + +
+ { + if (e.key === "Escape") onCancel(); + }} + disabled={submitting} + autoFocus + /> +
+ Saving…} + disabled={!email.trim()} + > + Save + + +
+ + +
+ ); +} diff --git a/src/components/profile/ProfileDescription.tsx b/src/components/profile/ProfileDescription.tsx new file mode 100644 index 00000000..a2628550 --- /dev/null +++ b/src/components/profile/ProfileDescription.tsx @@ -0,0 +1,91 @@ +/** + * Renders a profile's description, which the signed-in user edits in place on + * their own profile. + * + * @module + */ + +import { useState } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts"; +import { Markdown } from "../editor/Markdown.tsx"; +import { DescriptionEditor } from "./DescriptionEditor.tsx"; + +/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */ +export interface ProfileDescriptionProps { + description: string | undefined; + isOwnProfile: boolean; + /** Called with the saved description, undefined once cleared. */ + onSaved: (description: string | undefined) => void; +} + +/** + * Renders the description in Markdown, or nothing when there is none. On + * their own profile, users get a "Who am I?" placeholder instead, and a click + * or Enter swaps in {@link DescriptionEditor}. + */ +export function ProfileDescription( + { description, isOwnProfile, onSaved }: ProfileDescriptionProps, +): React.JSX.Element | null { + const [editing, setEditing] = useState(false); + const ref = useHeightTransition(editing); + + if (!description && !isOwnProfile) return null; + + return ( +
+ {editing + ? ( + setEditing(false)} + onSaved={(saved) => { + onSaved(saved); + setEditing(false); + }} + /> + ) + : ( +
setEditing(true) : undefined} + // Focusable rather than a button, so the Markdown inside keeps its + // own structure and links. + tabIndex={isOwnProfile ? 0 : undefined} + title={isOwnProfile ? t`Edit description` : undefined} + onKeyDown={isOwnProfile + ? (e) => { + if ( + (e.key === "Enter" || e.key === " ") && + e.target === e.currentTarget + ) { + e.preventDefault(); + setEditing(true); + } + } + : undefined} + > + {description + ? ( + + {description} + + ) + : ( +
+ Who am I? +
+ )} + {isOwnProfile && ( + + ✎ + + )} +
+ )} +
+ ); +} diff --git a/src/components/profile/ProfileDumpsTab.tsx b/src/components/profile/ProfileDumpsTab.tsx new file mode 100644 index 00000000..3e9bc1bc --- /dev/null +++ b/src/components/profile/ProfileDumpsTab.tsx @@ -0,0 +1,71 @@ +/** + * The "dumps" tab of a profile: the user's dumps beside those they upvoted. + * + * @module + */ + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import type { Dump } from "../../../shared/dump.ts"; +import type { PagedList } from "../../utils/feed/pagedList.ts"; +import { DumpCardList } from "../dump/DumpCardList.tsx"; +import { NewDumpButton } from "../dump/NewDumpButton.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; + +/** Passed to {@link ProfileDumpsTab} by `UserPublicProfile`. */ +export interface ProfileDumpsTabProps { + username: string; + dumps: PagedList; + votes: PagedList; + isOwnProfile: boolean; + /** From `useProfile`, which fades out a withdrawn upvote. */ + upvotedCardClassName: (dump: Dump) => string | undefined; +} + +/** + * Renders the first page of both lists, each linking to its full page. The + * owner also gets a "New dump" button. + */ +export function ProfileDumpsTab( + { username, dumps, votes, isOwnProfile, upvotedCardClassName }: + ProfileDumpsTabProps, +): React.JSX.Element { + return ( +
+ } + viewAllHref={dumps.items.length > 0 + ? `/users/${username}/dumps` + : undefined} + > + {dumps.items.length === 0 + ? ( +

+ Nothing here yet. +

+ ) + : } +
+ 0 + ? `/users/${username}/upvoted` + : undefined} + > + {votes.items.length === 0 + ? ( +

+ Nothing here yet. +

+ ) + : ( + + )} +
+
+ ); +} diff --git a/src/components/profile/ProfileFollowedTab.tsx b/src/components/profile/ProfileFollowedTab.tsx new file mode 100644 index 00000000..5ea16a1e --- /dev/null +++ b/src/components/profile/ProfileFollowedTab.tsx @@ -0,0 +1,119 @@ +/** + * The "followed" tab of a profile: the users and the playlists the user + * follows. + * + * @module + */ + +import { Link } from "react-router"; +import { Trans } from "@lingui/react/macro"; +import type { Playlist } from "../../../shared/playlist.ts"; +import type { PublicUser } from "../../../shared/user.ts"; +import type { PagedList } from "../../utils/feed/pagedList.ts"; +import type { FetchState } from "../../hooks/shared/useFetchState.ts"; +import { Avatar } from "../shared/Avatar.tsx"; +import { ListState } from "../shared/ListState.tsx"; +import { LoadMoreButton } from "../shared/LoadMoreButton.tsx"; +import { FollowUserButton } from "../social/FollowButton.tsx"; +import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; + +/** + * Loaded by `UserPublicProfile` when the tab is first opened. The followed + * users have no page of their own, so their list grows in the tab. + */ +export interface ProfileFollowed { + users: PagedList; + playlists: PagedList; +} + +/** Passed to {@link ProfileFollowedTab} by `UserPublicProfile`. */ +export interface ProfileFollowedTabProps { + username: string; + state: FetchState; + onLoadMoreUsers: () => void; +} + +/** Renders a followed user, with a button to follow or unfollow them. */ +function FollowedUserCard({ user }: { user: PublicUser }) { + return ( +
  • + + + @{user.username} + + +
  • + ); +} + +/** + * Renders the followed users, with a "Load more" button, beside the first + * page of the followed playlists, which link to the user's playlists page. + */ +export function ProfileFollowedTab( + { username, state, onLoadMoreUsers }: ProfileFollowedTabProps, +): React.JSX.Element { + return ( +
    + Following}> + data.users.items.length === 0} + empty={ +

    + Not following anyone yet. +

    + } + > + {({ data: { users } }) => ( + <> +
      + {users.items.map((u) => ( + + ))} +
    + {users.hasMore && ( + + )} + + )} +
    +
    + + Followed playlists} + viewAllHref={state.status === "loaded" && state.data.playlists.hasMore + ? `/users/${username}/playlists` + : undefined} + > + data.playlists.items.length === 0} + empty={ +

    + No followed playlists yet. +

    + } + > + {({ data }) => } +
    +
    +
    + ); +} diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx new file mode 100644 index 00000000..7f67333f --- /dev/null +++ b/src/components/profile/ProfileHeader.tsx @@ -0,0 +1,182 @@ +/** + * Renders the header of a user's profile, where the signed-in user also edits + * their own avatar and email. + * + * @module + */ + +import { useRef, useState } from "react"; +import { Link } from "react-router"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { PublicUser, SelfUser } from "../../../shared/user.ts"; +import { parseAPIResponse } from "../../utils/shared/api.ts"; +import { deserializeSelfUser } from "../../utils/shared/deserialize.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts"; +import { Avatar } from "../shared/Avatar.tsx"; +import { ErrorCard } from "../shared/ErrorCard.tsx"; +import { FollowUserButton } from "../social/FollowButton.tsx"; +import { EmailEditor } from "./EmailEditor.tsx"; +import { ProfileOwnActions } from "./ProfileOwnActions.tsx"; +import { RoleChip } from "./RoleSwitcher.tsx"; + +/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */ +export interface ProfileHeaderProps { + user: PublicUser; + isOwnProfile: boolean; + /** Called with the user as a new avatar left them. */ + onUserChange: (user: PublicUser) => void; +} + +/** + * Renders the avatar, username, role and inviter. Visitors get a follow + * button. On their own profile, users change their avatar + * (`POST /api/avatars/me`) and email, invite someone and log out. + */ +export function ProfileHeader( + { user, isOwnProfile, onUserChange }: ProfileHeaderProps, +): React.JSX.Element { + const { user: me, authFetch, updateSession, logout } = useAuth(); + const [uploading, setUploading] = useState(false); + const [avatarError, setAvatarError] = useState(null); + const fileInputRef = useRef(null); + const [emailEditing, setEmailEditing] = useState(false); + const infoRef = useHeightTransition(emailEditing); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setAvatarError(null); + setUploading(true); + try { + const formData = new FormData(); + formData.append("file", file); + const res = await authFetch(`${API_URL}/api/avatars/me`, { + method: "POST", + body: formData, + }); + const body = parseAPIResponse>(await res.json()); + if (!body.success) { + setAvatarError(body.error.message); + return; + } + const self = deserializeSelfUser(body.data); + updateSession({ user: self }); + onUserChange(self); + } catch { + setAvatarError(t`Upload failed`); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleEmailSaved = (email: string) => { + if (me) updateSession({ user: { ...me, email } }); + setEmailEditing(false); + }; + + return ( +
    +
    + + {isOwnProfile && ( + + )} +
    +
    +
    +
    +

    {user.username}

    + +
    + {user.invitedByUsername + ? ( +

    + invited by{" "} + + @{user.invitedByUsername} + +

    + ) + : ( +

    + O.G. +

    + )} + {isOwnProfile && ( + emailEditing + ? ( + setEmailEditing(false)} + onSaved={handleEmailSaved} + /> + ) + : ( +

    setEmailEditing(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setEmailEditing(true); + } + }} + tabIndex={0} + role="button" + title={t`Edit email`} + > + {me?.email ?? t`Add email…`} + + ✎ + +

    + ) + )} + {avatarError && ( + + )} + {!isOwnProfile && ( + + )} + {isOwnProfile && ( + + + + )} +
    +
    +
    + ); +} diff --git a/src/components/profile/ProfileInviteesTab.tsx b/src/components/profile/ProfileInviteesTab.tsx new file mode 100644 index 00000000..94bf8c6a --- /dev/null +++ b/src/components/profile/ProfileInviteesTab.tsx @@ -0,0 +1,90 @@ +/** + * The "invitees" tab of a profile: whom the user invited, and whom they + * invited in turn. + * + * @module + */ + +import { useMemo } from "react"; +import { Link } from "react-router"; +import { Trans } from "@lingui/react/macro"; +import type { Invitee } from "../../../shared/user.ts"; +import type { FetchState } from "../../hooks/shared/useFetchState.ts"; +import { Avatar } from "../shared/Avatar.tsx"; +import { ListState } from "../shared/ListState.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; + +type InviteTreeNode = Invitee & { children: InviteTreeNode[] }; + +/** + * Returns the invitees of `parentId` among `entries`, each with theirs. The + * server answers the whole tree as a flat list. + */ +function buildInviteTree( + entries: Invitee[], + parentId: string, +): InviteTreeNode[] { + return entries + .filter((e) => e.invitedById === parentId) + .map((e) => ({ ...e, children: buildInviteTree(entries, e.id) })); +} + +/** Renders the invite tree, each invitee nested under who invited them. */ +function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) { + return ( +
      + {nodes.map((node) => ( +
    • + + + @{node.username} + + {node.children.length > 0 && } +
    • + ))} +
    + ); +} + +/** Passed to {@link ProfileInviteesTab} by `UserPublicProfile`. */ +export interface ProfileInviteesTabProps { + userId: string; + /** + * Loaded when the tab is first opened, from + * `GET /api/users/:username/invitees`. + */ + state: FetchState; +} + +/** Renders the invitees of `userId` as a tree. */ +export function ProfileInviteesTab( + { userId, state }: ProfileInviteesTabProps, +): React.JSX.Element { + const nodes = useMemo( + () => state.status === "loaded" ? buildInviteTree(state.data, userId) : [], + [state, userId], + ); + return ( + Invitees}> + data.length === 0} + empty={ +

    + No invitees yet. +

    + } + > + {() => } +
    +
    + ); +} diff --git a/src/components/profile/ProfileManageTab.tsx b/src/components/profile/ProfileManageTab.tsx new file mode 100644 index 00000000..56c3ad58 --- /dev/null +++ b/src/components/profile/ProfileManageTab.tsx @@ -0,0 +1,55 @@ +/** + * The "manage" tab of a profile, which holds tools for another user on their + * profile and site-wide tools on one's own. + * + * @module + */ + +import { Trans } from "@lingui/react/macro"; +import type { PublicUser, Role } from "../../../shared/user.ts"; +import { CategoryManager } from "../category/CategoryManager.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; +import { RoleSwitcher } from "./RoleSwitcher.tsx"; + +/** + * Passed to {@link ProfileManageTab} by `UserPublicProfile`, which shows the + * tab only when one of its sections applies. + */ +export interface ProfileManageTabProps { + user: PublicUser; + isOwnProfile: boolean; + canManageUsers: boolean; + canManageCategories: boolean; + onRoleSaved: (role: Role) => void; +} + +/** + * Renders the user's role to those who hold `user:manage`, on another user's + * profile, and the {@link CategoryManager} to those who hold + * `category:manage`, on their own. + */ +export function ProfileManageTab( + { user, isOwnProfile, canManageUsers, canManageCategories, onRoleSaved }: + ProfileManageTabProps, +): React.JSX.Element { + return ( + <> + {!isOwnProfile && canManageUsers && ( + User management}> +
    + +
    +
    + )} + {isOwnProfile && canManageCategories && ( + Categories}> + + + )} + + ); +} diff --git a/src/components/profile/ProfileOwnActions.tsx b/src/components/profile/ProfileOwnActions.tsx new file mode 100644 index 00000000..60f08372 --- /dev/null +++ b/src/components/profile/ProfileOwnActions.tsx @@ -0,0 +1,119 @@ +/** + * Renders the actions of the signed-in user's own profile header: inviting + * someone, and whatever the header adds, the log out button. + * + * @module + */ + +import { useState } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { API_URL } from "../../config/api.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts"; +import { ErrorCard } from "../shared/ErrorCard.tsx"; + +/** + * Renders the "Invite someone" button, then `children`, the log out button. + * Each opening of the panel creates a new single-use invite link, for + * `/register`. + */ +export function ProfileOwnActions( + { children }: { children: React.ReactNode }, +): React.JSX.Element { + const { authFetch } = useAuth(); + const [inviteUrl, setInviteUrl] = useState(null); + const [pending, setPending] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const ref = useHeightTransition( + (inviteUrl ?? "") + (error ?? ""), + ); + + async function toggle() { + setError(null); + setCopied(false); + if (inviteUrl) { + setInviteUrl(null); + return; + } + setPending(true); + try { + const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" }); + const body = await res.json(); + if (body.success) { + setInviteUrl( + `${globalThis.location.origin}/register?token=${body.data.token}`, + ); + } else { + setError(t`Failed to generate invite`); + } + } catch { + setError(t`Failed to generate invite`); + } finally { + setPending(false); + } + } + + async function copy() { + if (!inviteUrl) return; + try { + await navigator.clipboard.writeText(inviteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // The clipboard is denied outside a secure context. The link can still + // be selected by hand. + } + } + + return ( +
    +
    + + {children} +
    + {inviteUrl && ( +
    + e.currentTarget.select()} + /> + + + Single use, valid for 7 days. + +
    + )} + {error && ( +
    + +
    + )} +
    + ); +} diff --git a/src/components/profile/ProfilePlaylistsTab.tsx b/src/components/profile/ProfilePlaylistsTab.tsx new file mode 100644 index 00000000..f8207a65 --- /dev/null +++ b/src/components/profile/ProfilePlaylistsTab.tsx @@ -0,0 +1,57 @@ +/** + * The "playlists" tab of a profile: the playlists the user made. + * + * @module + */ + +import { Trans } from "@lingui/react/macro"; +import type { Playlist } from "../../../shared/playlist.ts"; +import type { PagedList } from "../../utils/feed/pagedList.ts"; +import { withFirst } from "../../utils/feed/pagedList.ts"; +import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx"; +import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; + +/** Passed to {@link ProfilePlaylistsTab} by `UserPublicProfile`. */ +export interface ProfilePlaylistsTabProps { + username: string; + playlists: PagedList; + isOwnProfile: boolean; + /** From `useProfile`, to put a playlist made here first. */ + setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; +} + +/** + * Renders the first page of the playlists, linking to the full page. The + * owner also gets a "New playlist" button. + */ +export function ProfilePlaylistsTab( + { username, playlists, isOwnProfile, setPlaylists }: ProfilePlaylistsTabProps, +): React.JSX.Element { + return ( + + Playlists ({playlists.items.length} + {playlists.hasMore ? "+" : ""}) + + } + actions={isOwnProfile && ( + setPlaylists((items) => withFirst(items, p))} + /> + )} + viewAllHref={playlists.items.length > 0 + ? `/users/${username}/playlists` + : undefined} + > + {playlists.items.length === 0 + ? ( +

    + No playlists yet. +

    + ) + : } +
    + ); +} diff --git a/src/components/profile/ProfileSection.tsx b/src/components/profile/ProfileSection.tsx new file mode 100644 index 00000000..de07a4b6 --- /dev/null +++ b/src/components/profile/ProfileSection.tsx @@ -0,0 +1,48 @@ +/** + * Renders a titled section of a profile page. + * + * @module + */ + +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import { Trans } from "@lingui/react/macro"; + +/** + * Passed to {@link ProfileSection} by the profile's tabs and by + * `UserPlaylists`. + */ +export interface ProfileSectionProps { + title: ReactNode; + /** Buttons 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. + */ + viewAllHref?: string; + children: ReactNode; +} + +/** + * Renders a `.profile-section`: its title row, `children`, then a + * "View all →" link to `viewAllHref`. + */ +export function ProfileSection( + { title, actions, viewAllHref, children }: ProfileSectionProps, +): React.JSX.Element { + return ( +
    +
    +

    {title}

    + {actions} +
    + {children} + {viewAllHref && ( + + View all → + + )} +
    + ); +} diff --git a/src/components/profile/ProfileSettingsTab.tsx b/src/components/profile/ProfileSettingsTab.tsx new file mode 100644 index 00000000..d9ba562e --- /dev/null +++ b/src/components/profile/ProfileSettingsTab.tsx @@ -0,0 +1,128 @@ +/** + * The "settings" tab of the signed-in user's own profile: their password, and + * this browser's preferences. + * + * @module + */ + +import { useState } from "react"; +import { Trans } from "@lingui/react/macro"; +import type { + ColorScheme, + StyleName, +} from "../../contexts/theme/ThemeContext.ts"; +import type { FeedTab } from "../../config/feedTabs.ts"; +import { useTheme } from "../../hooks/theme/useTheme.ts"; +import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts"; +import { useSoundsEnabled } from "../../hooks/shared/useSoundsEnabled.ts"; +import { previewChime } from "../../utils/shared/sound.ts"; +import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx"; +import { ChangePasswordModal } from "./ChangePasswordModal.tsx"; +import { ProfileSection } from "./ProfileSection.tsx"; +import { SettingRow } from "./SettingRow.tsx"; + +// The styles' names are their own, in every language. +const STYLE_OPTIONS: SegmentedOption[] = [ + { value: "smooth", label: "Smooth" }, + { value: "brutalist", label: "Brutalist" }, + { value: "geocities", label: "GeoCities" }, + { value: "nyt", label: "Times" }, + { value: "kanagawa", label: "Kanagawa" }, + { value: "melange", label: "Melange" }, +]; + +const COLOR_SCHEME_OPTIONS: SegmentedOption[] = [ + { value: "auto", label: Auto }, + { value: "light", label: Light }, + { value: "dark", label: Dark }, +]; + +const FEED_TAB_OPTIONS: SegmentedOption[] = [ + { value: "hot", label: Hot }, + { value: "new", label: New }, + { value: "journal", label: Journal }, + { value: "followed", label: Followed }, +]; + +const ON_OFF_OPTIONS: SegmentedOption[] = [ + { value: true, label: On }, + { value: false, label: Off }, +]; + +/** + * Renders the password change, which opens {@link ChangePasswordModal}, and + * the style, color scheme, default feed tab and sounds, each stored for this + * browser as soon as it is clicked. `UserPublicProfile` renders it on one's + * own profile only. + */ +export function ProfileSettingsTab(): React.JSX.Element { + const { style, colorScheme, setStyle, setColorScheme } = useTheme(); + const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); + const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); + const [changePasswordOpen, setChangePasswordOpen] = useState(false); + + return ( + <> + Account}> +
    + Password}> + + +
    +
    + Appearance}> +
    + Style}> + + + Color scheme}> + + +
    +
    + Feeds}> +
    + Default tab}> + + +
    +
    + Notifications}> +
    + Sounds}> + { + setSoundsEnabled(on); + // Let the user hear what they just turned on. + if (on) previewChime("notify"); + }} + /> + +
    +
    + {changePasswordOpen && ( + setChangePasswordOpen(false)} /> + )} + + ); +} diff --git a/src/components/profile/RoleSwitcher.tsx b/src/components/profile/RoleSwitcher.tsx new file mode 100644 index 00000000..f21f0450 --- /dev/null +++ b/src/components/profile/RoleSwitcher.tsx @@ -0,0 +1,95 @@ +/** + * Shows a user's role on their profile, and lets those who hold `user:manage` + * change it. + * + * @module + */ + +import { useState } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { PublicUser, Role } from "../../../shared/user.ts"; +import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; +import { deserializePublicUser } from "../../utils/shared/deserialize.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; +import { ErrorCard } from "../shared/ErrorCard.tsx"; +import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx"; +import { SettingRow } from "./SettingRow.tsx"; + +const ROLE_OPTIONS: SegmentedOption[] = [ + { value: "user", label: User }, + { value: "moderator", label: Moderator }, + { value: "admin", label: Admin }, +]; + +/** + * Renders the role badge next to the username in `ProfileHeader`, for + * moderators and admins only. + */ +export function RoleChip({ role }: { role: Role }): React.JSX.Element | null { + if (role === "user") return null; + const label = ROLE_OPTIONS.find((o) => o.value === role)?.label; + return ( + + {label} + + ); +} + +/** + * Passed to {@link RoleSwitcher} by `ProfileManageTab`, which shows it only to + * those who hold `user:manage`, on another user's profile. + */ +export interface RoleSwitcherProps { + userId: string; + currentRole: Role; + /** Called with the role the server kept. */ + onSaved: (role: Role) => void; +} + +/** + * Renders the user's role as a {@link Segmented} whose clicks save it to + * `PATCH /api/users/:id/role`, which checks the permission again. + */ +export function RoleSwitcher( + { userId, currentRole, onSaved }: RoleSwitcherProps, +): React.JSX.Element { + const { authFetch } = useAuth(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function changeRole(role: Role) { + if (role === currentRole || saving) return; + setSaving(true); + setError(null); + try { + const res = await authFetch(`${API_URL}/api/users/${userId}/role`, { + method: "PATCH", + body: JSON.stringify({ role }), + }); + onSaved( + deserializePublicUser(await expectOk>(res)).role, + ); + } catch (err) { + setError(friendlyFetchError(err)); + } finally { + setSaving(false); + } + } + + return ( + <> + Role}> + + + {error && } + + ); +} diff --git a/src/components/profile/SettingRow.tsx b/src/components/profile/SettingRow.tsx new file mode 100644 index 00000000..6dec2d18 --- /dev/null +++ b/src/components/profile/SettingRow.tsx @@ -0,0 +1,31 @@ +/** + * Renders one labelled setting of the profile's "settings" and "manage" tabs. + * + * @module + */ + +import type { ReactNode } from "react"; + +/** + * Passed to {@link SettingRow} by `ProfileSettingsTab` and `RoleSwitcher`. + */ +export interface SettingRowProps { + label: ReactNode; + /** The control, usually a `Segmented`. */ + children: ReactNode; +} + +/** + * Renders `label` beside `children`, as a row of the + * `.profile-appearance-grid` its caller wraps the rows in. + */ +export function SettingRow( + { label, children }: SettingRowProps, +): React.JSX.Element { + return ( +
    + {label} + {children} +
    + ); +} diff --git a/src/components/shared/ListState.tsx b/src/components/shared/ListState.tsx new file mode 100644 index 00000000..6cb312a3 --- /dev/null +++ b/src/components/shared/ListState.tsx @@ -0,0 +1,46 @@ +/** + * Renders a loaded list, or in its place the loading, error or empty state. + * + * @module + */ + +import type { ReactNode } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import type { LoadState } from "../../utils/shared/loadState.ts"; +import { ErrorCard } from "./ErrorCard.tsx"; + +/** + * Passed to {@link ListState} by the feeds of the index and the lists of a + * profile. + */ +export interface ListStateProps { + state: LoadState; + /** Called only once `state` is loaded. */ + isEmpty: (loaded: L) => boolean; + /** Shown when `isEmpty`, usually a `.empty-state` or `.index-status` line. */ + empty: ReactNode; + /** Called only once `state` is loaded and not empty. */ + children: (loaded: L) => ReactNode; +} + +/** + * Renders "Loading…" while `state` loads, its error in an {@link ErrorCard}, + * then `empty` or `children`. Those two are only built once loaded, so they can + * read the loaded fields without checking the status again. + */ +export function ListState( + { state, isEmpty, empty, children }: ListStateProps, +): ReactNode { + if (state.status === "loading") { + return ( +

    + Loading… +

    + ); + } + if (state.status === "error") { + return ; + } + return isEmpty(state) ? empty : children(state); +} diff --git a/src/components/shared/LoadMoreButton.tsx b/src/components/shared/LoadMoreButton.tsx new file mode 100644 index 00000000..9465bb30 --- /dev/null +++ b/src/components/shared/LoadMoreButton.tsx @@ -0,0 +1,35 @@ +/** + * Renders the button that loads a list's next page, where the list has no + * room to load it on scroll. + * + * @module + */ + +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. + */ +export interface LoadMoreButtonProps { + loadingMore: boolean; + onClick: () => void; + /** `load-more-btn` by default. */ + className?: string; +} + +/** Renders "Load more", disabled and reading "Loading…" while it loads. */ +export function LoadMoreButton( + { loadingMore, onClick, className = "load-more-btn" }: LoadMoreButtonProps, +): React.JSX.Element { + return ( + + ); +} diff --git a/src/components/shared/Segmented.tsx b/src/components/shared/Segmented.tsx new file mode 100644 index 00000000..28b0cf75 --- /dev/null +++ b/src/components/shared/Segmented.tsx @@ -0,0 +1,51 @@ +/** + * Renders a choice among a few values as a row of buttons, in the + * `.visibility-toggle` style. + * + * @module + */ + +import type { ReactNode } from "react"; + +/** + * Passed in {@link SegmentedProps.options}. Its `value` is compared with `===` + * and must be unique as a string, since it keys the button. + */ +export interface SegmentedOption { + value: V; + label: ReactNode; +} + +/** + * Passed to {@link Segmented} by `SegmentedField` for a form, and by the + * profile's settings and role switcher, which save each click at once. + */ +export interface SegmentedProps { + options: SegmentedOption[]; + value: V; + onChange: (value: V) => void; + disabled?: boolean; + /** Added to `visibility-toggle`. */ + className?: string; +} + +/** Renders the options as buttons, the one equal to `value` marked active. */ +export function Segmented( + { options, value, onChange, disabled, className }: SegmentedProps, +): React.JSX.Element { + return ( +
    + {options.map((opt) => ( + + ))} +
    + ); +} diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx index 9452d8dd..cc501276 100644 --- a/src/components/social/UserListPopover.tsx +++ b/src/components/social/UserListPopover.tsx @@ -11,6 +11,7 @@ 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 { deserializePublicUser } from "../../utils/shared/deserialize.ts"; @@ -251,16 +252,11 @@ export function UserListPopover( ))} {state.hasMore && ( - + /> )} )} diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts index 17fb0200..78614510 100644 --- a/src/contexts/ws/WSContext.ts +++ b/src/contexts/ws/WSContext.ts @@ -62,7 +62,7 @@ export interface CommentEvent { /** * Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the - * server sends on any change to a profile. `UserPublicProfile` applies it. + * server sends on any change to a profile. `useProfile` applies it. */ export interface UserEvent { user: PublicUser; diff --git a/src/hooks/dump/useUpvotedList.ts b/src/hooks/dump/useUpvotedList.ts new file mode 100644 index 00000000..bb78d4fc --- /dev/null +++ b/src/hooks/dump/useUpvotedList.ts @@ -0,0 +1,142 @@ +/** + * Keeps a list of the dumps a user upvoted in step with their votes. + * + * @module + */ + +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; + +import { API_URL } from "../../config/api.ts"; +import type { Dump } from "../../../shared/dump.ts"; +import type { Wire } from "../../../shared/api.ts"; +import { expectOk } from "../../utils/shared/api.ts"; +import { deserializeDump } from "../../utils/shared/deserialize.ts"; +import { useAuth } from "../auth/useAuth.ts"; +import { useWS } from "../ws/useWS.ts"; +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. */ + 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. + */ + source?: Dump[]; +} + +/** Returned by {@link useUpvotedList}. */ +export interface UseUpvotedListResult { + /** For `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. + */ + isWithdrawn: (id: string) => boolean; +} + +/** + * Follows the votes of `profileUserId` into their upvoted list. A dump they + * upvote is fetched and put first. One whose vote they withdraw fades out + * (`useFading`), then leaves the list, unless they vote again meanwhile. The + * socket's vote events report every browser's votes; on one's own list, + * `myVotes` also reports this tab's at once. + */ +export function useUpvotedList( + { profileUserId, setItems, source }: UseUpvotedListOptions, +): UseUpvotedListResult { + const { user: me, token } = useAuth(); + const { myVotes, lastVoteEvent } = useWS(); + const isOwn = !!profileUserId && me?.id === profileUserId; + + const withdrawnRef = useRef>(new Set()); + const prevMyVotesRef = useRef | null>(null); + // A ref, so that a new first page doesn't count as new votes. + const sourceRef = useRef(source); + useLayoutEffect(() => { + sourceRef.current = source; + }); + + const { fading, startFading, cancelFading, cancelAll } = useFading((id) => + setItems((items) => items.filter((d) => d.id !== id)) + ); + + const withdraw = useCallback((id: string) => { + withdrawnRef.current.add(id); + startFading(id); + }, [startFading]); + const recast = useCallback((id: string) => { + withdrawnRef.current.delete(id); + cancelFading(id); + }, [cancelFading]); + + // Another user's list starts afresh. This runs before the effect below that + // diffs the votes, so its baseline resets in time. + useEffect(() => { + withdrawnRef.current = new Set(); + prevMyVotesRef.current = null; + cancelAll(); + }, [profileUserId, cancelAll]); + + useEffect(() => { + 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. + 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))); + for (const id of added) recast(id); + const known = (sourceRef.current ?? []).filter((d) => added.has(d.id)); + if (known.length === 0) return; + setItems((items) => { + const ids = new Set(items.map((d) => d.id)); + const toAdd = known.filter((d) => !ids.has(d.id)); + return toAdd.length === 0 ? items : [...toAdd, ...items]; + }); + }, [myVotes, isOwn, withdraw, recast, setItems]); + + useEffect(() => { + if (!lastVoteEvent || !profileUserId) return; + const { dumpId, voterId, action } = lastVoteEvent; + if (voterId !== profileUserId) return; + if (action === "remove") { + withdraw(dumpId); + return; + } + recast(dumpId); + // Fetched signed in, or a private dump the viewer owns would be refused. + fetch(`${API_URL}/api/dumps/${dumpId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + .then(expectOk>) + .then((wire) => { + const dump = deserializeDump(wire); + setItems((items) => + // A withdrawn vote cast again before its dump left the list. + items.some((d) => d.id === dumpId) + ? items.map((d) => d.id === dumpId ? dump : d) + : [dump, ...items] + ); + }) + .catch(() => {}); + }, [lastVoteEvent, profileUserId, token, withdraw, recast, setItems]); + + const cardClassName = useCallback( + (dump: Dump) => fadeClassName(fading[dump.id]), + [fading], + ); + const isWithdrawn = useCallback( + (id: string) => withdrawnRef.current.has(id), + [], + ); + + return { cardClassName, isWithdrawn }; +} diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts index e051bf76..e9fdfa2f 100644 --- a/src/hooks/dump/useUserDumpFeed.ts +++ b/src/hooks/dump/useUserDumpFeed.ts @@ -5,29 +5,23 @@ * @module */ -import { - type RefCallback, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +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 { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; +import { fetchFirstPage } from "../../utils/feed/pagedList.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, - deserializePublicUser, hydrateDump, } from "../../utils/shared/deserialize.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useFeedCache } from "../feed/useFeedCache.ts"; import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts"; -import { useScrollSave } from "../feed/useScrollSave.ts"; +import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; /** * Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails @@ -46,18 +40,10 @@ export type UserDumpFeedState = loadingMore: boolean; }; -/** Passed to {@link useUserDumpFeed} by `UserUpvoted`. */ -export interface UseUserDumpFeedOptions { - /** Called with the dumps of each page after the first, once appended. */ - onItemsAppended?: (items: Dump[]) => void; -} - /** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */ export interface UseUserDumpFeedResult { state: UserDumpFeedState; - /** Lets `UserUpvoted` prepend a dump the user just upvoted. */ - setState: React.Dispatch>; - /** Does nothing until loaded. Passed to `useDumpListSync`. */ + /** Does nothing until loaded. Passed to `useDumpListSync` and `useUpvotedList`. */ setItems: (fn: (prev: Dump[]) => Dump[]) => void; /** From `useInfiniteScroll`, for an element after the last card. */ sentinelRef: RefCallback; @@ -75,7 +61,6 @@ export function useUserDumpFeed( username: string | undefined, endpoint: string, cacheKey: string, - options?: UseUserDumpFeedOptions, ): UseUserDumpFeedResult { const { token } = useAuth(); const { cached, saveState } = useFeedCache(cacheKey, hydrateDump); @@ -94,64 +79,32 @@ export function useUserDumpFeed( useEffect(() => { if (!username) return; const controller = new AbortController(); - - if (cached) { - fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) - .then((r) => r.json()) - .then((body) => { - if (!body.success) throw new Error("User not found"); - setState({ - status: "loaded", - profileUser: deserializePublicUser(body.data), - items: cached.items, - hasMore: cached.hasMore, - page: cached.page, - loadingMore: false, - }); - }) - .catch((err) => { - if (err.name === "AbortError") return; - setState({ status: "error", error: friendlyFetchError(err) }); - }); - return () => controller.abort(); - } - - const authHeaders: HeadersInit = token - ? { Authorization: `Bearer ${token}` } - : {}; - Promise.all([ - fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }), - fetch( - `${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { headers: authHeaders, signal: controller.signal }, - ), - ]) - .then(([userRes, itemsRes]) => - Promise.all([userRes.json(), itemsRes.json()]) - ) - .then(([userBody, itemsBody]) => { - if (!userBody.success) throw new Error("User not found"); - const { items, hasMore } = itemsBody.success - ? itemsBody.data as PaginatedData> - : { items: [], hasMore: false }; + 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 }, + ); + Promise.all([fetchPublicUser(username, signal), list]) + .then(([profileUser, { items, hasMore, page }]) => setState({ status: "loaded", - profileUser: deserializePublicUser(userBody.data), - items: items.map(deserializeDump), + profileUser, + items, hasMore, - page: 1, + page, loadingMore: false, - }); - }) + }) + ) .catch((err) => { - if (err.name === "AbortError") return; + if (signal.aborted) return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); }, [username, endpoint, cached, token]); - const { onItemsAppended } = options ?? {}; - const loadMore = useCallback(() => { if ( state.status !== "loaded" || !state.hasMore || state.loadingMore || @@ -177,14 +130,13 @@ export function useUserDumpFeed( } : s ); - onItemsAppended?.(newItems); }) .catch(() => setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); - }, [state, username, token, endpoint, onItemsAppended]); + }, [state, username, token, endpoint]); const sentinelRef = useInfiniteScroll( loadMore, @@ -199,14 +151,7 @@ export function useUserDumpFeed( }, [state, saveState]), ); - const scrollRestored = useRef(false); - useLayoutEffect(() => { - if (cached?.scrollY == null || scrollRestored.current) return; - if (state.status === "loaded") { - globalThis.scrollTo(0, cached.scrollY); - scrollRestored.current = true; - } - }, [state.status, cached]); + useScrollRestore(cached?.scrollY, state.status === "loaded"); - return { state, setState, setItems, sentinelRef }; + return { state, setItems, sentinelRef }; } diff --git a/src/hooks/feed/useFading.ts b/src/hooks/feed/useFading.ts index 6e8100e2..087f046e 100644 --- a/src/hooks/feed/useFading.ts +++ b/src/hooks/feed/useFading.ts @@ -1,100 +1,114 @@ /** - * Times the fade-out of dumps leaving an upvoted list, so that a vote withdrawn - * by mistake can be cast again before the dump is gone. + * Times the fade-out of items leaving a list, so that a removal made by + * mistake can be undone before the item is gone. * * @module */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; /** - * The phase of a fading item, which the list maps to a CSS class. `cooldown` - * lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS collapse. + * 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. */ export type FadeState = "cooldown" | "dismissing"; +const COOLDOWN_MS = 2000; +const DISMISS_MS = 350; + /** - * Returned by {@link useFading} to `UserUpvoted` and the profile's upvoted - * section. The list keeps showing an item while it is in `fading`, then drops - * it. + * Returned by {@link useFading} to `useUpvotedList` and `PlaylistDetail`. The + * list keeps showing an item while it is in `fading`. */ export interface UseFadingResult { fading: Record; /** Does nothing when `id` is already fading. */ startFading: (id: string) => void; - /** Called when the vote is cast again: the item stays. */ + /** Called when the removal is undone: the item stays. */ cancelFading: (id: string) => void; - /** Called when the list changes to another user's. */ + /** Called when the list is replaced, as by another user's or a reload. */ cancelAll: () => void; } /** - * Returns the fading state of a list's items and the calls that drive it. The - * timers are cleared on unmount. + * Returns the fading state of a list's items and the calls that drive it. + * `onFaded` is called with an item whose fade ran to its end, for the list to + * drop it; a cancelled fade doesn't call it. The timers are cleared on + * unmount. */ -export function useFading(): UseFadingResult { +export function useFading(onFaded?: (id: string) => void): UseFadingResult { const [fading, setFading] = useState>({}); - const cancels = useRef void>>(new Map()); + // The running timer of each fading item, whichever phase it is in. + const timers = useRef>>(new Map()); + // A ref, so that the calls below stay stable for their callers' effects. + const onFadedRef = useRef(onFaded); + useLayoutEffect(() => { + onFadedRef.current = onFaded; + }); - useEffect(() => () => { - cancels.current.forEach((c) => c()); + useEffect(() => { + const running = timers.current; + return () => running.forEach(clearTimeout); + }, []); + + const drop = useCallback((id: string) => { + timers.current.delete(id); + setFading((f) => { + const n = { ...f }; + delete n[id]; + return n; + }); }, []); const startFading = useCallback((id: string) => { - if (cancels.current.has(id)) return; - // `kill` is replaced as each timer is set, so it always clears the one - // running; `dead` stops a timer that fired before it was cleared. - let dead = false; - let kill = () => {}; - kill = () => { - dead = true; - setFading((f) => { - const n = { ...f }; - delete n[id]; - return n; - }); - cancels.current.delete(id); - }; - cancels.current.set(id, () => kill()); + if (timers.current.has(id)) return; setFading((f) => ({ ...f, [id]: "cooldown" })); - const t1 = setTimeout(() => { - if (dead) return; - setFading((f) => ({ ...f, [id]: "dismissing" })); - const t2 = setTimeout(() => { - if (!dead) kill(); - }, 350); - kill = () => { - dead = true; - clearTimeout(t2); - setFading((f) => { - const n = { ...f }; - delete n[id]; - return n; - }); - cancels.current.delete(id); - }; - cancels.current.set(id, () => kill()); - }, 2000); - kill = () => { - dead = true; - clearTimeout(t1); - setFading((f) => { - const n = { ...f }; - delete n[id]; - return n; - }); - cancels.current.delete(id); - }; - cancels.current.set(id, () => kill()); - }, []); + timers.current.set( + id, + setTimeout(() => { + setFading((f) => ({ ...f, [id]: "dismissing" })); + timers.current.set( + id, + setTimeout(() => { + drop(id); + onFadedRef.current?.(id); + }, DISMISS_MS), + ); + }, COOLDOWN_MS), + ); + }, [drop]); const cancelFading = useCallback((id: string) => { - cancels.current.get(id)?.(); - }, []); + const timer = timers.current.get(id); + if (timer === undefined) return; + clearTimeout(timer); + drop(id); + }, [drop]); const cancelAll = useCallback(() => { - cancels.current.forEach((c) => c()); - }, []); + for (const id of [...timers.current.keys()]) cancelFading(id); + }, [cancelFading]); return { fading, startFading, cancelFading, cancelAll }; } + +/** + * Returns the class a fading card takes in `phase`, for `DumpCard`'s + * `className`, or undefined for a card that isn't fading. + */ +export function fadeClassName( + phase: FadeState | undefined, +): string | undefined { + return phase === "cooldown" + ? "dump-card--fading" + : phase === "dismissing" + ? "dump-card--dismissing" + : undefined; +} diff --git a/src/hooks/feed/useScrollSave.ts b/src/hooks/feed/useScrollSave.ts index 0fa81cfe..15773968 100644 --- a/src/hooks/feed/useScrollSave.ts +++ b/src/hooks/feed/useScrollSave.ts @@ -1,5 +1,6 @@ /** - * Saves a feed page's state, with its scroll position, as the page is left. + * Saves a feed page's state, with its scroll position, as the page is left, + * and restores the position when the page is back. * * @module */ @@ -42,3 +43,21 @@ export function useScrollSave( }; }, [enabled]); } + +/** + * Scrolls the window to `scrollY` once `loaded`, and never again, before + * paint so that the restored page doesn't flash at the top. Feed pages pass + * `useFeedCache`'s `cached?.scrollY`, which is undefined when nothing was + * restored. + */ +export function useScrollRestore( + scrollY: number | null | undefined, + loaded: boolean, +): void { + const restored = useRef(false); + useLayoutEffect(() => { + if (scrollY == null || restored.current || !loaded) return; + globalThis.scrollTo(0, scrollY); + restored.current = true; + }, [scrollY, loaded]); +} diff --git a/src/hooks/playlist/usePlaylistMemberships.ts b/src/hooks/playlist/usePlaylistMemberships.ts new file mode 100644 index 00000000..716a2b8e --- /dev/null +++ b/src/hooks/playlist/usePlaylistMemberships.ts @@ -0,0 +1,84 @@ +/** + * Loads which of the signed-in user's playlists hold a dump, and adds the dump + * to them or takes it out. + * + * @module + */ + +import { useCallback } from "react"; +import { API_URL } from "../../config/api.ts"; +import type { PlaylistMembership } from "../../../shared/playlist.ts"; +import type { Wire } from "../../../shared/api.ts"; +import { expectOk } from "../../utils/shared/api.ts"; +import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts"; +import { useAuth } from "../auth/useAuth.ts"; +import { useFetchState } from "../shared/useFetchState.ts"; + +/** + * Returned by {@link usePlaylistMemberships} to `AddToPlaylistModal` and + * `DumpCreateModal`, for their `PlaylistMembershipPanel`. + */ +export interface UsePlaylistMembershipsResult { + /** Empty while loading, and when the load failed. */ + memberships: PlaylistMembership[]; + loading: boolean; + /** + * Adds the dump to the playlist or takes it out on the server, and shows it + * only once the server agrees. A refusal or a dropped connection changes + * nothing. + */ + toggle: (membership: PlaylistMembership) => Promise; + /** Puts first a playlist the panel just created with the dump in it. */ + add: (membership: PlaylistMembership) => void; +} + +/** + * Returns the user's playlists with whether each holds `dumpId`, from + * `GET /api/playlists/by-dump/:dumpId/memberships`. A null `dumpId`, as before + * `DumpCreateModal` has posted its dump, loads nothing. + */ +export function usePlaylistMemberships( + dumpId: string | null, +): UsePlaylistMembershipsResult { + const { authFetch } = useAuth(); + const { state, setData } = useFetchState( + dumpId ?? undefined, + (signal) => + authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`, { + signal, + }) + .then(expectOk[]>) + .then((items) => items.map(deserializePlaylistMembership)), + ); + + const toggle = useCallback(async (membership: PlaylistMembership) => { + const { playlist, hasDump } = membership; + try { + const res = await authFetch( + `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, + { method: hasDump ? "DELETE" : "POST" }, + ); + if (!res.ok) return; + } catch { + return; + } + setData((memberships) => + memberships.map((m) => + m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m + ) + ); + }, [authFetch, dumpId, setData]); + + const add = useCallback( + (membership: PlaylistMembership) => + setData((memberships) => [membership, ...memberships]), + [setData], + ); + + return { + memberships: state.status === "loaded" ? state.data : [], + loading: state.status === "loading", + toggle, + add, + }; +} diff --git a/src/hooks/profile/useProfile.ts b/src/hooks/profile/useProfile.ts new file mode 100644 index 00000000..6fc38c40 --- /dev/null +++ b/src/hooks/profile/useProfile.ts @@ -0,0 +1,233 @@ +/** + * Loads a profile's user with the first page of their dumps, upvotes and + * playlists, and keeps them live. + * + * @module + */ + +import { useCallback, useEffect, useState } from "react"; + +import { API_URL } from "../../config/api.ts"; +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 { friendlyFetchError } from "../../utils/shared/api.ts"; +import { + deserializeDump, + deserializePlaylist, + hydrateDump, + hydratePlaylist, +} from "../../utils/shared/deserialize.ts"; +import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; +import type { LoadState } from "../../utils/shared/loadState.ts"; +import { + fetchFirstPage, + type PagedList, + restoredList, +} from "../../utils/feed/pagedList.ts"; +import { useAuth } from "../auth/useAuth.ts"; +import { useWS } from "../ws/useWS.ts"; +import { useFeedCache } from "../feed/useFeedCache.ts"; +import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; +import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts"; +import { useDumpListSync } from "../dump/useDumpListSync.ts"; +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. + */ +export interface ProfileData { + user: PublicUser; + dumps: PagedList; + votes: PagedList; + playlists: PagedList; +} + +/** Returned by {@link useProfile} to `UserPublicProfile`. */ +export interface UseProfileResult { + state: LoadState; + /** Whether the signed-in user is the profile's. False while it loads. */ + isOwnProfile: boolean; + /** For 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. */ + setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; + /** From `useUpvotedList`, for the upvoted list's `DumpCardList`. */ + upvotedCardClassName: (dump: Dump) => string | undefined; +} + +type ListKey = "dumps" | "votes" | "playlists"; + +/** Returns `s` with `fn` applied to the items of its list `key`. */ +function updateList( + s: LoadState, + key: K, + fn: (items: ProfileData[K]["items"]) => ProfileData[K]["items"], +): LoadState { + if (s.status !== "loaded") return s; + return { ...s, [key]: { ...s[key], items: fn(s[key].items) } }; +} + +/** + * Returns the profile of `username`, from `GET /api/users/:username`, with the + * first pages of `…/dumps`, `…/votes` and `…/playlists`, fetched signed in so + * that users see their own private items. A list the server refuses shows + * empty; the page fails only with the user. The socket's events keep the lists + * live, and `useUpvotedList` the upvoted one. The lists and the scroll + * position are saved as the page is left, and going back restores them, + * refetching only the user. + */ +export function useProfile(username: string | undefined): UseProfileResult { + const { user: me, token } = useAuth(); + const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS(); + + const dumpsCache = useFeedCache( + `feed:profile-dumps:${username ?? ""}`, + hydrateDump, + ); + const votesCache = useFeedCache( + `feed:profile-votes:${username ?? ""}`, + hydrateDump, + ); + const playlistsCache = useFeedCache( + `feed:profile-playlists:${username ?? ""}`, + hydratePlaylist, + ); + + const [state, setState] = useState>({ + status: "loading", + }); + const [prevUsername, setPrevUsername] = useState(username); + if (prevUsername !== username) { + setPrevUsername(username); + setState({ status: "loading" }); + } + + const profileUserId = state.status === "loaded" ? state.user.id : null; + const isOwnProfile = !!me && me.id === profileUserId; + + const setUser = useCallback((fn: (user: PublicUser) => PublicUser) => { + setState((s) => s.status === "loaded" ? { ...s, user: fn(s.user) } : s); + }, []); + const setDumps = useCallback((fn: (items: Dump[]) => Dump[]) => { + setState((s) => updateList(s, "dumps", fn)); + }, []); + const setVotes = useCallback((fn: (items: Dump[]) => Dump[]) => { + setState((s) => updateList(s, "votes", fn)); + }, []); + const setPlaylists = useCallback((fn: (items: Playlist[]) => Playlist[]) => { + setState((s) => updateList(s, "playlists", fn)); + }, []); + + const dumpItems = state.status === "loaded" ? state.dumps.items : []; + usePositionAwareSync( + dumpItems, + setDumps, + lastDumpEvent, + (d) => !d.isPrivate && d.userId === profileUserId, + ); + useDumpListSync(setDumps, { isOwner: isOwnProfile }); + + const upvoted = useUpvotedList({ + profileUserId, + setItems: setVotes, + source: dumpItems, + }); + const voteItems = state.status === "loaded" ? state.votes.items : []; + usePositionAwareSync( + voteItems, + setVotes, + lastDumpEvent, + (d) => !d.isPrivate && !upvoted.isWithdrawn(d.id), + ); + useDumpListSync(setVotes); + + const playlistItems = state.status === "loaded" ? state.playlists.items : []; + const lastPlaylistItem = lastPlaylistEvent?.type === "updated" + ? (lastPlaylistEvent.playlist ?? null) + : null; + usePositionAwareSync( + playlistItems, + setPlaylists, + lastPlaylistItem, + (p) => p.isPublic && p.userId === profileUserId, + ); + usePlaylistListSync(setPlaylists, { + isOwner: isOwnProfile, + ownerId: profileUserId ?? undefined, + }); + + useEffect(() => { + if (!lastUserEvent) return; + // Mirrors a socket event into state, which can only happen in an effect. + // eslint-disable-next-line react-hooks/set-state-in-effect + setUser((u) => u.id === lastUserEvent.user.id ? lastUserEvent.user : u); + }, [lastUserEvent, setUser]); + + const cachedDumps = dumpsCache.cached; + const cachedVotes = votesCache.cached; + const cachedPlaylists = playlistsCache.cached; + useEffect(() => { + if (!username) return; + const controller = new AbortController(); + const { signal } = controller; + const headers: HeadersInit = token + ? { Authorization: `Bearer ${token}` } + : {}; + const firstPageOf = (list: string, deserialize: (w: Wire) => T) => + fetchFirstPage(`${API_URL}/api/users/${username}/${list}`, deserialize, { + headers, + signal, + }); + + // Back on the page, the lists come from the cache and only the user is + // fetched again. + const cached = cachedDumps && cachedVotes && cachedPlaylists; + Promise.all([ + fetchPublicUser(username, signal), + cached + ? restoredList(cachedDumps) + : firstPageOf("dumps", deserializeDump), + cached + ? restoredList(cachedVotes) + : firstPageOf("votes", deserializeDump), + cached + ? restoredList(cachedPlaylists) + : firstPageOf("playlists", deserializePlaylist), + ]) + .then(([user, dumps, votes, playlists]) => + setState({ status: "loaded", user, dumps, votes, playlists }) + ) + .catch((err) => { + if (signal.aborted) return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); + }, [username, cachedDumps, cachedVotes, cachedPlaylists, token]); + + const saveDumps = dumpsCache.saveState; + const saveVotes = votesCache.saveState; + const savePlaylists = playlistsCache.saveState; + useScrollSave( + state.status === "loaded", + 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); + }, [state, saveDumps, saveVotes, savePlaylists]), + ); + useScrollRestore(cachedDumps?.scrollY, state.status === "loaded"); + + return { + state, + isOwnProfile, + setUser, + setPlaylists, + upvotedCardClassName: upvoted.cardClassName, + }; +} diff --git a/src/hooks/shared/useFetchState.ts b/src/hooks/shared/useFetchState.ts new file mode 100644 index 00000000..7ca110f4 --- /dev/null +++ b/src/hooks/shared/useFetchState.ts @@ -0,0 +1,81 @@ +/** + * Loads one thing from the API, once per key, when first asked to. + * + * @module + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { LoadState } from "../../utils/shared/loadState.ts"; +import { friendlyFetchError } from "../../utils/shared/api.ts"; + +/** Held by {@link useFetchState}, and passed on to `ListState`. */ +export type FetchState = LoadState<{ data: T }>; + +/** Returned by {@link useFetchState}. */ +export interface UseFetchStateResult { + state: FetchState; + /** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */ + 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. + */ +export function useFetchState( + key: string | undefined, + load: (signal: AbortSignal) => Promise, + enabled = true, +): UseFetchStateResult { + const [state, setState] = useState>({ status: "loading" }); + const [prevKey, setPrevKey] = useState(key); + if (prevKey !== key) { + setPrevKey(key); + setState({ status: "loading" }); + } + + // A ref, so that a new `load` on every render doesn't load again. + const loadRef = useRef(load); + useLayoutEffect(() => { + loadRef.current = load; + }); + + // The key whose load has started, and the controller that aborts it. + const startedRef = useRef(null); + const controllerRef = useRef(null); + useEffect(() => () => { + controllerRef.current?.abort(); + startedRef.current = null; + }, [key]); + + useEffect(() => { + if (!enabled || key === undefined || startedRef.current === key) return; + startedRef.current = key; + const controller = new AbortController(); + controllerRef.current = controller; + loadRef.current(controller.signal) + .then((data) => setState({ status: "loaded", data })) + .catch((err) => { + if (controller.signal.aborted) return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + }, [enabled, key]); + + const setData = useCallback((fn: (data: T) => T) => { + setState((s) => + s.status === "loaded" ? { status: "loaded", data: fn(s.data) } : s + ); + }, []); + + return { state, setData }; +} diff --git a/src/locales/en.po b/src/locales/en.po index 27d32613..0685257a 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -55,7 +55,7 @@ msgstr "{0} more online: {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" -#: src/pages/Search.tsx:201 +#: src/pages/Search.tsx:200 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -67,9 +67,9 @@ msgstr "{unseenCount} new messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" -#: src/components/dump/create/DumpCreateModal.tsx:778 -#: src/pages/PlaylistDetail.tsx:630 -#: src/pages/UserPublicProfile.tsx:853 +#: src/components/dump/create/DumpCreateModal.tsx:741 +#: src/pages/PlaylistDetail.tsx:580 +#: src/pages/UserPublicProfile.tsx:167 msgid "← Back" msgstr "← Back" @@ -79,13 +79,13 @@ msgstr "← Back" msgid "← Back to all dumps" msgstr "← Back to all dumps" -#: src/pages/UserDumps.tsx:76 -#: src/pages/UserPlaylists.tsx:380 -#: src/pages/UserUpvoted.tsx:169 +#: src/pages/UserDumps.tsx:74 +#: src/pages/UserPlaylists.tsx:306 +#: src/pages/UserUpvoted.tsx:60 msgid "← Back to profile" msgstr "← Back to profile" -#: src/pages/UserPublicProfile.tsx:142 +#: src/components/profile/ProfileOwnActions.tsx:80 msgid "+ Invite someone" msgstr "+ Invite someone" @@ -99,47 +99,47 @@ msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:211 +#: src/pages/Notifications.tsx:212 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} commented on <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:171 +#: src/pages/Notifications.tsx:172 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} followed your playlist <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:221 +#: src/pages/Notifications.tsx:222 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} liked your comment on <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:237 +#: src/pages/Notifications.tsx:238 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} mentioned you in <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:181 +#: src/pages/Notifications.tsx:182 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:162 +#: src/pages/Notifications.tsx:163 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:201 +#: src/pages/Notifications.tsx:202 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:191 +#: src/pages/Notifications.tsx:192 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" @@ -147,15 +147,15 @@ msgstr "<0>{0} was added to <1>{1}" msgid "1 new message" msgstr "1 new message" -#: src/pages/Notifications.tsx:232 +#: src/pages/Notifications.tsx:233 msgid "a comment" msgstr "a comment" -#: src/pages/Notifications.tsx:235 +#: src/pages/Notifications.tsx:236 msgid "a post" msgstr "a post" -#: src/pages/UserPublicProfile.tsx:1261 +#: src/components/profile/ProfileSettingsTab.tsx:66 msgid "Account" msgstr "Account" @@ -168,7 +168,7 @@ msgstr "Add a comment…" msgid "Add category" msgstr "Add category" -#: src/pages/UserPublicProfile.tsx:939 +#: src/components/profile/ProfileHeader.tsx:152 msgid "Add email…" msgstr "Add email…" @@ -176,12 +176,12 @@ msgstr "Add email…" msgid "Add image" msgstr "Add image" -#: src/components/dump/create/DumpCreateModal.tsx:500 -#: src/components/playlist/AddToPlaylistModal.tsx:72 +#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/playlist/AddToPlaylistModal.tsx:29 msgid "Add to playlist" msgstr "Add to playlist" -#: src/pages/UserPublicProfile.tsx:1726 +#: src/components/profile/RoleSwitcher.tsx:24 msgid "Admin" msgstr "Admin" @@ -204,7 +204,7 @@ msgstr "Already have an account? <0>Log in" msgid "An unexpected error occurred." msgstr "An unexpected error occurred." -#: src/pages/UserPublicProfile.tsx:1280 +#: src/components/profile/ProfileSettingsTab.tsx:79 msgid "Appearance" msgstr "Appearance" @@ -219,7 +219,7 @@ msgstr "At least {0} characters" msgid "At most {maxLength} characters." msgstr "At most {maxLength} characters." -#: src/pages/UserPublicProfile.tsx:1342 +#: src/components/profile/ProfileSettingsTab.tsx:35 msgid "Auto" msgstr "Auto" @@ -237,18 +237,18 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may #: src/components/category/CategoryManager.tsx:226 #: src/components/chat/ChatEditBox.tsx:88 #: src/components/comment/CommentForm.tsx:127 -#: src/components/dump/create/DumpCreateModal.tsx:788 +#: src/components/dump/create/DumpCreateModal.tsx:751 #: src/components/form/FormActions.tsx:42 +#: src/components/profile/DescriptionEditor.tsx:96 +#: 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:1014 -#: src/pages/UserPublicProfile.tsx:1863 -#: src/pages/UserPublicProfile.tsx:1942 +#: src/pages/PlaylistDetail.tsx:948 msgid "Cancel" msgstr "Cancel" -#: src/pages/PlaylistDetail.tsx:808 +#: src/pages/PlaylistDetail.tsx:753 msgid "Cancel removal" msgstr "Cancel removal" @@ -262,7 +262,7 @@ msgid "Cancel search" msgstr "Cancel search" #: src/components/category/CategorySelect.tsx:33 -#: src/pages/UserPublicProfile.tsx:1457 +#: src/components/profile/ProfileManageTab.tsx:49 msgid "Categories" msgstr "Categories" @@ -270,7 +270,7 @@ msgstr "Categories" msgid "Category" msgstr "Category" -#: src/pages/UserPublicProfile.tsx:880 +#: src/components/profile/ProfileHeader.tsx:93 msgid "Change avatar" msgstr "Change avatar" @@ -283,7 +283,7 @@ msgstr "Change image" msgid "Change password" msgstr "Change password" -#: src/pages/UserPublicProfile.tsx:1273 +#: src/components/profile/ProfileSettingsTab.tsx:74 msgid "Change password…" msgstr "Change password…" @@ -312,7 +312,7 @@ msgstr "Checking invite…" msgid "Close" msgstr "Close" -#: src/components/dump/create/DumpCreateModal.tsx:531 +#: src/components/dump/create/DumpCreateModal.tsx:495 msgid "Close anyway" msgstr "Close anyway" @@ -324,7 +324,7 @@ msgstr "Close emoji picker" msgid "Close player" msgstr "Close player" -#: src/components/dump/create/DumpCreateModal.tsx:521 +#: src/components/dump/create/DumpCreateModal.tsx:485 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Close without posting? The attached file won't be kept — everything you typed will." @@ -332,7 +332,7 @@ msgstr "Close without posting? The attached file won't be kept — everything yo msgid "Collapse player" msgstr "Collapse player" -#: src/pages/UserPublicProfile.tsx:1334 +#: src/components/profile/ProfileSettingsTab.tsx:88 msgid "Color scheme" msgstr "Color scheme" @@ -345,11 +345,11 @@ msgstr "Confirm new password" msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" -#: src/pages/UserPublicProfile.tsx:167 +#: src/components/profile/ProfileOwnActions.tsx:105 msgid "Copied!" msgstr "Copied!" -#: src/pages/UserPublicProfile.tsx:167 +#: src/components/profile/ProfileOwnActions.tsx:105 msgid "Copy" msgstr "Copy" @@ -362,11 +362,11 @@ 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:412 +#: src/pages/UserPlaylists.tsx:330 msgid "Could not delete" msgstr "Could not delete" -#: src/components/social/UserListPopover.tsx:166 +#: src/components/social/UserListPopover.tsx:167 msgid "Could not load." msgstr "Could not load." @@ -374,7 +374,7 @@ msgstr "Could not load." msgid "Could not save" msgstr "Could not save" -#: src/components/dump/create/DumpCreateModal.tsx:643 +#: src/components/dump/create/DumpCreateModal.tsx:606 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." @@ -396,7 +396,7 @@ msgstr "Create & Add" #. placeholder {0}: created.items.length #. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:418 +#: src/pages/UserPlaylists.tsx:335 msgid "Created ({0}{1})" msgstr "Created ({0}{1})" @@ -408,11 +408,11 @@ msgstr "Creating…" msgid "Current password" msgstr "Current password" -#: src/pages/UserPublicProfile.tsx:1356 +#: src/components/profile/ProfileSettingsTab.tsx:37 msgid "Dark" msgstr "Dark" -#: src/pages/UserPublicProfile.tsx:1369 +#: src/components/profile/ProfileSettingsTab.tsx:99 msgid "Default tab" msgstr "Default tab" @@ -424,7 +424,7 @@ msgstr "Default tab" #: src/components/comment/CommentNode.tsx:263 #: src/components/comment/CommentNode.tsx:269 #: src/components/shared/ConfirmModal.tsx:34 -#: src/pages/PlaylistDetail.tsx:1010 +#: src/pages/PlaylistDetail.tsx:944 msgid "Delete" msgstr "Delete" @@ -448,8 +448,8 @@ msgid "Delete failed ({0})" msgstr "Delete failed ({0})" #: src/components/playlist/PlaylistCard.tsx:132 -#: src/pages/PlaylistDetail.tsx:821 -#: src/pages/UserPlaylists.tsx:495 +#: src/pages/PlaylistDetail.tsx:766 +#: src/pages/UserPlaylists.tsx:393 msgid "Delete playlist" msgstr "Delete playlist" @@ -465,8 +465,8 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/pages/PlaylistDetail.tsx:820 -#: src/pages/UserPlaylists.tsx:494 +#: src/pages/PlaylistDetail.tsx:765 +#: src/pages/UserPlaylists.tsx:392 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -475,7 +475,7 @@ msgid "deleted message" msgstr "deleted message" #: src/components/playlist/PlaylistCreateForm.tsx:104 -#: src/pages/PlaylistDetail.tsx:971 +#: src/pages/PlaylistDetail.tsx:916 msgid "Description (optional)" msgstr "Description (optional)" @@ -492,7 +492,7 @@ msgstr "Disconnected — reconnecting…" msgid "Dismiss" msgstr "Dismiss" -#: src/components/dump/create/DumpCreateModal.tsx:582 +#: src/components/dump/create/DumpCreateModal.tsx:545 msgid "Done" msgstr "Done" @@ -505,7 +505,7 @@ msgstr "Download {0}" msgid "Drop a file here" msgstr "Drop a file here" -#: src/components/dump/create/DumpCreateModal.tsx:689 +#: src/components/dump/create/DumpCreateModal.tsx:652 msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" @@ -517,27 +517,27 @@ msgstr "Drop a replacement here" msgid "Dump" msgstr "Dump" -#: src/components/dump/create/DumpCreateModal.tsx:801 +#: src/components/dump/create/DumpCreateModal.tsx:764 msgid "Dump it" msgstr "Dump it" -#: src/components/dump/create/DumpCreateModal.tsx:559 +#: src/components/dump/create/DumpCreateModal.tsx:523 msgid "Dumped!" msgstr "Dumped!" -#: src/pages/Search.tsx:197 -#: src/pages/UserDumps.tsx:90 -#: src/pages/UserPublicProfile.tsx:1026 +#: src/pages/Search.tsx:196 +#: src/pages/UserDumps.tsx:88 +#: src/pages/UserPublicProfile.tsx:197 msgid "Dumps" msgstr "Dumps" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1046 +#: src/components/profile/ProfileDumpsTab.tsx:36 msgid "Dumps ({0}{1})" msgstr "Dumps ({0}{1})" -#: src/pages/Notifications.tsx:472 +#: src/pages/Notifications.tsx:473 msgid "Earlier" msgstr "Earlier" @@ -546,7 +546,7 @@ msgstr "Earlier" #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:254 #: src/pages/Dump.tsx:550 -#: src/pages/PlaylistDetail.tsx:684 +#: src/pages/PlaylistDetail.tsx:634 msgid "Edit" msgstr "Edit" @@ -555,11 +555,11 @@ msgstr "Edit" msgid "Edit {0}" msgstr "Edit {0}" -#: src/pages/UserPublicProfile.tsx:990 +#: src/components/profile/ProfileDescription.tsx:58 msgid "Edit description" msgstr "Edit description" -#: src/pages/UserPublicProfile.tsx:937 +#: src/components/profile/ProfileHeader.tsx:150 msgid "Edit email" msgstr "Edit email" @@ -578,7 +578,7 @@ msgstr "edited" #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 #: src/pages/Dump.tsx:491 -#: src/pages/PlaylistDetail.tsx:723 +#: src/pages/PlaylistDetail.tsx:673 msgid "edited {0}" msgstr "edited {0}" @@ -588,7 +588,7 @@ msgstr "edited {0}" #: src/components/chat/ChatMessageItem.tsx:124 #: src/components/comment/CommentNode.tsx:201 #: src/pages/Dump.tsx:489 -#: src/pages/PlaylistDetail.tsx:720 +#: src/pages/PlaylistDetail.tsx:670 msgid "Edited {0}" msgstr "Edited {0}" @@ -605,7 +605,7 @@ msgstr "Editing {0}" msgid "Email address" msgstr "Email address" -#: src/pages/Search.tsx:230 +#: src/pages/Search.tsx:229 msgid "Enter a query to search." msgstr "Enter a query to search." @@ -625,23 +625,18 @@ msgstr "Failed to create playlist" msgid "Failed to delete" msgstr "Failed to delete" -#: src/pages/UserPublicProfile.tsx:111 -#: src/pages/UserPublicProfile.tsx:114 -#: src/pages/UserPublicProfile.tsx:176 +#: src/components/profile/ProfileOwnActions.tsx:49 +#: src/components/profile/ProfileOwnActions.tsx:52 +#: src/components/profile/ProfileOwnActions.tsx:114 msgid "Failed to generate invite" msgstr "Failed to generate invite" -#: src/components/feed/DumpFeed.tsx:87 -#: src/components/feed/FollowedFeed.tsx:105 -#: src/components/feed/JournalFeed.tsx:45 -#: src/pages/Notifications.tsx:446 -#: src/pages/UserPublicProfile.tsx:1148 -#: src/pages/UserPublicProfile.tsx:1194 -#: src/pages/UserPublicProfile.tsx:1239 +#: src/components/shared/ListState.tsx:43 +#: src/pages/Notifications.tsx:447 msgid "Failed to load" msgstr "Failed to load" -#: src/components/dump/create/DumpCreateModal.tsx:591 +#: src/components/dump/create/DumpCreateModal.tsx:554 msgid "Failed to post" msgstr "Failed to post" @@ -653,9 +648,9 @@ msgstr "Failed to post comment" msgid "Failed to post reply" msgstr "Failed to post reply" -#: src/pages/PlaylistDetail.tsx:1002 -#: src/pages/UserPublicProfile.tsx:1866 -#: src/pages/UserPublicProfile.tsx:1944 +#: src/components/profile/DescriptionEditor.tsx:98 +#: src/components/profile/EmailEditor.tsx:86 +#: src/pages/PlaylistDetail.tsx:936 msgid "Failed to save" msgstr "Failed to save" @@ -667,23 +662,23 @@ msgstr "Failed to save category" msgid "Failed to save edit" msgstr "Failed to save edit" -#: src/pages/UserPublicProfile.tsx:948 +#: src/components/profile/ProfileHeader.tsx:161 msgid "Failed to update avatar" msgstr "Failed to update avatar" -#: src/pages/UserPublicProfile.tsx:1800 +#: src/components/profile/RoleSwitcher.tsx:92 msgid "Failed to update role" msgstr "Failed to update role" -#: src/pages/UserPublicProfile.tsx:1364 +#: src/components/profile/ProfileSettingsTab.tsx:97 msgid "Feeds" msgstr "Feeds" -#: src/components/dump/create/DumpCreateModal.tsx:630 +#: src/components/dump/create/DumpCreateModal.tsx:593 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/dump/create/DumpCreateModal.tsx:420 +#: src/components/dump/create/DumpCreateModal.tsx:416 msgid "File too large (max {max})." msgstr "File too large (max {max})." @@ -700,33 +695,33 @@ msgstr "Follow {targetUsername}" msgid "Follow playlist" msgstr "Follow playlist" -#: src/components/feed/FollowedFeed.tsx:388 +#: src/components/feed/FollowedFeed.tsx:215 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:375 +#: src/components/feed/FollowedFeed.tsx:207 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/pages/UserPublicProfile.tsx:1028 -#: src/pages/UserPublicProfile.tsx:1398 +#: src/components/profile/ProfileSettingsTab.tsx:44 +#: src/pages/UserPublicProfile.tsx:199 msgid "Followed" msgstr "Followed" #. placeholder {0}: followed.items.length #. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:460 +#: src/pages/UserPlaylists.tsx:366 msgid "Followed ({0}{1})" msgstr "Followed ({0}{1})" -#: src/pages/UserPublicProfile.tsx:1183 +#: src/components/profile/ProfileFollowedTab.tsx:100 msgid "Followed playlists" msgstr "Followed playlists" +#: src/components/profile/ProfileFollowedTab.tsx:68 #: src/components/social/FollowButton.tsx:62 #: src/components/social/FollowButton.tsx:102 -#: src/pages/UserPublicProfile.tsx:1137 msgid "Following" msgstr "Following" @@ -734,11 +729,11 @@ msgstr "Following" msgid "Forgot password?" msgstr "Forgot password?" -#: src/components/feed/FollowedFeed.tsx:359 +#: src/components/feed/FollowedFeed.tsx:196 msgid "From people" msgstr "From people" -#: src/components/feed/FollowedFeed.tsx:360 +#: src/components/feed/FollowedFeed.tsx:197 msgid "From playlists" msgstr "From playlists" @@ -755,7 +750,7 @@ msgid "Go to login" msgstr "Go to login" #: src/components/feed/FeedTabBar.tsx:44 -#: src/pages/UserPublicProfile.tsx:1377 +#: src/components/profile/ProfileSettingsTab.tsx:41 msgid "Hot" msgstr "Hot" @@ -767,7 +762,7 @@ msgstr "If that address is registered you'll receive a reset link shortly." msgid "image" msgstr "image" -#: src/pages/Dump.tsx:582 +#: src/pages/Dump.tsx:570 msgid "In collections" msgstr "In collections" @@ -784,30 +779,30 @@ msgstr "Invalid invite" msgid "Invalid link" msgstr "Invalid link" -#: src/pages/UserPublicProfile.tsx:150 -#: src/pages/UserPublicProfile.tsx:157 +#: src/components/profile/ProfileOwnActions.tsx:88 +#: src/components/profile/ProfileOwnActions.tsx:95 msgid "Invite link" msgstr "Invite link" -#: src/pages/UserPublicProfile.tsx:902 +#: src/components/profile/ProfileHeader.tsx:115 msgid "invited by" msgstr "invited by" -#: src/pages/UserPublicProfile.tsx:1029 -#: src/pages/UserPublicProfile.tsx:1228 +#: src/components/profile/ProfileInviteesTab.tsx:76 +#: src/pages/UserPublicProfile.tsx:200 msgid "Invitees" msgstr "Invitees" #: src/components/feed/FeedTabBar.tsx:46 -#: src/pages/UserPublicProfile.tsx:1391 +#: src/components/profile/ProfileSettingsTab.tsx:43 msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:528 +#: src/components/dump/create/DumpCreateModal.tsx:492 msgid "Keep editing" msgstr "Keep editing" -#: src/pages/UserPublicProfile.tsx:1349 +#: src/components/profile/ProfileSettingsTab.tsx:36 msgid "Light" msgstr "Light" @@ -815,7 +810,7 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/components/dump/create/DumpCreateModal.tsx:605 +#: src/components/dump/create/DumpCreateModal.tsx:568 msgid "Link" msgstr "Link" @@ -831,9 +826,7 @@ msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates unavailable." msgstr "Live updates unavailable." -#: src/components/social/UserListPopover.tsx:262 -#: src/pages/Notifications.tsx:519 -#: src/pages/UserPublicProfile.tsx:1174 +#: src/components/shared/LoadMoreButton.tsx:32 msgid "Load more" msgstr "Load more" @@ -850,43 +843,29 @@ msgstr "Loading dump…" msgid "Loading messages…" msgstr "Loading messages…" -#: src/components/feed/DumpFeed.tsx:115 -#: src/components/feed/FollowedFeed.tsx:131 -#: src/components/feed/JournalFeed.tsx:86 -#: src/pages/Search.tsx:267 -#: src/pages/UserDumps.tsx:131 -#: src/pages/UserPlaylists.tsx:447 -#: src/pages/UserPlaylists.tsx:482 -#: src/pages/UserUpvoted.tsx:222 +#: src/components/feed/FeedFooter.tsx:34 msgid "Loading more…" msgstr "Loading more…" -#: src/pages/PlaylistDetail.tsx:614 +#: src/pages/PlaylistDetail.tsx:564 msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:836 +#: src/pages/UserPublicProfile.tsx:150 msgid "Loading profile…" msgstr "Loading profile…" #: src/components/category/CategoryManager.tsx:60 #: src/components/chat/ChatModal.tsx:740 #: src/components/editor/EmojiPickerPopup.tsx:125 -#: src/components/feed/DumpFeed.tsx:83 -#: src/components/feed/FollowedFeed.tsx:100 -#: src/components/feed/JournalFeed.tsx:41 #: src/components/playlist/PlaylistMembershipPanel.tsx:45 -#: src/components/social/UserListPopover.tsx:223 -#: src/components/social/UserListPopover.tsx:261 -#: src/pages/Notifications.tsx:442 -#: src/pages/Notifications.tsx:518 -#: src/pages/UserDumps.tsx:64 -#: src/pages/UserPlaylists.tsx:368 -#: src/pages/UserPublicProfile.tsx:1142 -#: src/pages/UserPublicProfile.tsx:1173 -#: src/pages/UserPublicProfile.tsx:1188 -#: src/pages/UserPublicProfile.tsx:1233 -#: src/pages/UserUpvoted.tsx:157 +#: src/components/shared/ListState.tsx:38 +#: src/components/shared/LoadMoreButton.tsx:32 +#: src/components/social/UserListPopover.tsx:224 +#: src/pages/Notifications.tsx:443 +#: src/pages/UserDumps.tsx:62 +#: src/pages/UserPlaylists.tsx:294 +#: src/pages/UserUpvoted.tsx:48 msgid "Loading…" msgstr "Loading…" @@ -905,8 +884,8 @@ msgstr "Log in to like" msgid "Log in to vote" msgstr "Log in to vote" -#: src/pages/UserPublicProfile.tsx:857 -#: src/pages/UserPublicProfile.tsx:961 +#: src/components/profile/ProfileHeader.tsx:174 +#: src/pages/UserPublicProfile.tsx:171 msgid "Log out" msgstr "Log out" @@ -918,7 +897,7 @@ msgstr "Logging in…" msgid "Login failed" msgstr "Login failed" -#: src/pages/UserPublicProfile.tsx:1034 +#: src/pages/UserPublicProfile.tsx:205 msgid "Manage" msgstr "Manage" @@ -926,7 +905,7 @@ msgstr "Manage" msgid "Max {limit}" msgstr "Max {limit}" -#: src/pages/UserPublicProfile.tsx:1725 +#: src/components/profile/RoleSwitcher.tsx:23 msgid "Moderator" msgstr "Moderator" @@ -939,21 +918,20 @@ msgstr "Mute" msgid "Name" msgstr "Name" -#: src/pages/Notifications.tsx:435 +#: src/pages/Notifications.tsx:436 msgid "new" msgstr "new" #: src/components/chat/ChatModal.tsx:669 #: src/components/feed/FeedTabBar.tsx:45 -#: src/pages/UserPublicProfile.tsx:1384 +#: src/components/profile/ProfileSettingsTab.tsx:42 msgid "New" msgstr "New" -#: src/components/dump/create/DumpCreateModal.tsx:500 +#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/dump/NewDumpButton.tsx:27 #: src/components/layout/DumpFab.tsx:66 #: src/components/layout/DumpFab.tsx:67 -#: src/pages/UserDumps.tsx:98 -#: src/pages/UserPublicProfile.tsx:1505 msgid "New dump" msgstr "New dump" @@ -967,7 +945,7 @@ msgstr "New password" msgid "New playlist" msgstr "New playlist" -#: src/components/dump/create/DumpCreateModal.tsx:800 +#: src/components/dump/create/DumpCreateModal.tsx:763 msgid "Next →" msgstr "Next →" @@ -975,16 +953,16 @@ msgstr "Next →" msgid "Next track" msgstr "Next track" -#: src/pages/PlaylistDetail.tsx:743 +#: src/pages/PlaylistDetail.tsx:693 msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." -#: src/pages/Search.tsx:247 +#: src/pages/Search.tsx:246 msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." -#: src/components/feed/DumpFeed.tsx:91 -#: src/components/feed/JournalFeed.tsx:49 +#: src/components/feed/DumpFeed.tsx:74 +#: src/components/feed/JournalFeed.tsx:36 msgid "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!" @@ -992,12 +970,12 @@ msgstr "No dumps yet. Be the first!" msgid "No emoji found." msgstr "No emoji found." -#: src/pages/UserPlaylists.tsx:469 -#: src/pages/UserPublicProfile.tsx:1201 +#: src/components/profile/ProfileFollowedTab.tsx:110 +#: src/pages/UserPlaylists.tsx:375 msgid "No followed playlists yet." msgstr "No followed playlists yet." -#: src/pages/UserPublicProfile.tsx:1246 +#: src/components/profile/ProfileInviteesTab.tsx:82 msgid "No invitees yet." msgstr "No invitees yet." @@ -1005,17 +983,17 @@ msgstr "No invitees yet." msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" -#: src/components/social/UserListPopover.tsx:231 +#: src/components/social/UserListPopover.tsx:232 msgid "No one yet." msgstr "No one yet." -#: src/pages/Search.tsx:306 +#: src/pages/Search.tsx:288 msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/pages/UserPlaylists.tsx:427 -#: src/pages/UserPublicProfile.tsx:1108 +#: src/components/profile/ProfilePlaylistsTab.tsx:51 +#: src/pages/UserPlaylists.tsx:344 msgid "No playlists yet." msgstr "No playlists yet." @@ -1023,26 +1001,26 @@ msgstr "No playlists yet." 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:280 +#: src/pages/Search.tsx:262 msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." -#: src/pages/UserPublicProfile.tsx:1155 +#: src/components/profile/ProfileFollowedTab.tsx:74 msgid "Not following anyone yet." msgstr "Not following anyone yet." -#: src/pages/Notifications.tsx:453 -#: src/pages/UserDumps.tsx:109 -#: src/pages/UserPublicProfile.tsx:1516 -#: src/pages/UserPublicProfile.tsx:1639 -#: src/pages/UserUpvoted.tsx:191 +#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/components/profile/ProfileDumpsTab.tsx:59 +#: src/pages/Notifications.tsx:454 +#: src/pages/UserDumps.tsx:94 +#: src/pages/UserUpvoted.tsx:79 msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/layout/NotificationBell.tsx:54 -#: src/pages/Notifications.tsx:294 -#: src/pages/Notifications.tsx:431 -#: src/pages/UserPublicProfile.tsx:1406 +#: src/components/profile/ProfileSettingsTab.tsx:108 +#: src/pages/Notifications.tsx:295 +#: src/pages/Notifications.tsx:432 msgid "Notifications" msgstr "Notifications" @@ -1050,11 +1028,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" -#: src/pages/UserPublicProfile.tsx:1430 +#: src/components/profile/ProfileSettingsTab.tsx:49 msgid "Off" msgstr "Off" -#: src/pages/UserPublicProfile.tsx:1423 +#: src/components/profile/ProfileSettingsTab.tsx:48 msgid "On" msgstr "On" @@ -1083,8 +1061,8 @@ msgstr "or <0>browse files" msgid "Page not found" msgstr "Page not found" +#: src/components/profile/ProfileSettingsTab.tsx:68 #: src/pages/UserLogin.tsx:82 -#: src/pages/UserPublicProfile.tsx:1266 msgid "Password" msgstr "Password" @@ -1106,7 +1084,7 @@ msgstr "Password updated" msgid "Passwords do not match" msgstr "Passwords do not match" -#: src/components/dump/create/DumpCreateModal.tsx:623 +#: src/components/dump/create/DumpCreateModal.tsx:586 msgid "Paste a link…" msgstr "Paste a link…" @@ -1117,7 +1095,7 @@ msgstr "Paste a link…" msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:540 +#: src/components/dump/create/DumpCreateModal.tsx:504 msgid "Picked up where you left off." msgstr "Picked up where you left off." @@ -1131,22 +1109,22 @@ msgstr "Picked up where you left off." msgid "Play" msgstr "Play" -#: src/pages/PlaylistDetail.tsx:955 +#: src/pages/PlaylistDetail.tsx:900 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:200 -#: src/pages/UserPlaylists.tsx:394 -#: src/pages/UserPublicProfile.tsx:1027 +#: src/pages/Search.tsx:199 +#: src/pages/UserPlaylists.tsx:320 +#: src/pages/UserPublicProfile.tsx:198 msgid "Playlists" msgstr "Playlists" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1077 +#: src/components/profile/ProfilePlaylistsTab.tsx:34 msgid "Playlists ({0}{1})" msgstr "Playlists ({0}{1})" @@ -1160,7 +1138,7 @@ msgstr "Post reply" #: src/components/comment/CommentNode.tsx:288 #: src/components/comment/CommentThread.tsx:100 -#: src/components/dump/create/DumpCreateModal.tsx:794 +#: src/components/dump/create/DumpCreateModal.tsx:757 msgid "Posting…" msgstr "Posting…" @@ -1173,22 +1151,20 @@ msgstr "Previous track" #: src/components/playlist/PlaylistCard.tsx:95 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/pages/Dump.tsx:497 -#: src/pages/PlaylistDetail.tsx:703 +#: src/pages/PlaylistDetail.tsx:653 msgid "private" msgstr "private" -#: src/components/form/SegmentedField.tsx:100 -#: src/pages/PlaylistDetail.tsx:996 +#: src/components/form/SegmentedField.tsx:65 msgid "Private" msgstr "Private" #: src/components/playlist/PlaylistCard.tsx:94 -#: src/pages/PlaylistDetail.tsx:702 +#: src/pages/PlaylistDetail.tsx:652 msgid "public" msgstr "public" -#: src/components/form/SegmentedField.tsx:99 -#: src/pages/PlaylistDetail.tsx:989 +#: src/components/form/SegmentedField.tsx:64 msgid "Public" msgstr "Public" @@ -1226,7 +1202,7 @@ msgstr "Related" msgid "Remove file" msgstr "Remove file" -#: src/pages/PlaylistDetail.tsx:798 +#: src/pages/PlaylistDetail.tsx:743 msgid "Remove from playlist" msgstr "Remove from playlist" @@ -1280,28 +1256,28 @@ msgstr "Reset to the suggested title" msgid "Retry" msgstr "Retry" -#: src/pages/UserPublicProfile.tsx:1784 +#: src/components/profile/RoleSwitcher.tsx:84 msgid "Role" 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/profile/EmailEditor.tsx:75 #: src/pages/Dump.tsx:424 #: src/pages/DumpEdit.tsx:545 -#: src/pages/PlaylistDetail.tsx:1021 -#: src/pages/UserPublicProfile.tsx:1855 -#: src/pages/UserPublicProfile.tsx:1934 +#: src/pages/PlaylistDetail.tsx:955 msgid "Save" msgstr "Save" #: src/components/comment/CommentNode.tsx:215 #: src/components/profile/ChangePasswordModal.tsx:120 +#: src/components/profile/DescriptionEditor.tsx:85 +#: src/components/profile/EmailEditor.tsx:72 #: src/pages/Dump.tsx:423 -#: src/pages/PlaylistDetail.tsx:1017 +#: src/pages/PlaylistDetail.tsx:951 #: src/pages/ResetPassword.tsx:136 -#: src/pages/UserPublicProfile.tsx:1852 -#: src/pages/UserPublicProfile.tsx:1931 msgid "Saving…" msgstr "Saving…" @@ -1323,11 +1299,11 @@ msgstr "Search emoji" msgid "Search emoji…" msgstr "Search emoji…" -#: src/pages/Search.tsx:241 +#: src/pages/Search.tsx:240 msgid "Search failed" msgstr "Search failed" -#: src/pages/Search.tsx:236 +#: src/pages/Search.tsx:235 msgid "Searching…" msgstr "Searching…" @@ -1357,7 +1333,7 @@ msgstr "Server unreachable" msgid "Set new password" msgstr "Set new password" -#: src/pages/UserPublicProfile.tsx:1031 +#: src/pages/UserPublicProfile.tsx:202 msgid "Settings" msgstr "Settings" @@ -1365,7 +1341,7 @@ msgstr "Settings" msgid "Several people are typing…" msgstr "Several people are typing…" -#: src/pages/UserPublicProfile.tsx:170 +#: src/components/profile/ProfileOwnActions.tsx:108 msgid "Single use, valid for 7 days." msgstr "Single use, valid for 7 days." @@ -1380,19 +1356,19 @@ 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:737 +#: src/pages/PlaylistDetail.tsx:687 msgid "Something went wrong" msgstr "Something went wrong" -#: src/pages/UserPublicProfile.tsx:1411 +#: src/components/profile/ProfileSettingsTab.tsx:110 msgid "Sounds" msgstr "Sounds" -#: src/components/dump/create/DumpCreateModal.tsx:547 +#: src/components/dump/create/DumpCreateModal.tsx:511 msgid "Start fresh" msgstr "Start fresh" -#: src/pages/UserPublicProfile.tsx:1285 +#: src/components/profile/ProfileSettingsTab.tsx:81 msgid "Style" msgstr "Style" @@ -1404,15 +1380,15 @@ msgstr "Submit search" msgid "That file is over {limit}." msgstr "That file is over {limit}." -#: src/pages/Notifications.tsx:234 +#: src/pages/Notifications.tsx:235 msgid "the chat" msgstr "the chat" -#: src/pages/PlaylistDetail.tsx:563 +#: 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:511 +#: src/pages/PlaylistDetail.tsx:461 msgid "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again." @@ -1450,11 +1426,11 @@ msgstr "Thumbnail" msgid "Title" msgstr "Title" -#: src/components/dump/create/DumpCreateModal.tsx:417 +#: src/components/dump/create/DumpCreateModal.tsx:413 msgid "Title is required." msgstr "Title is required." -#: src/pages/Notifications.tsx:469 +#: src/pages/Notifications.tsx:470 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Today" @@ -1472,7 +1448,7 @@ msgstr "Try again" msgid "Type a message…" msgstr "Type a message…" -#: src/pages/PlaylistDetail.tsx:810 +#: src/pages/PlaylistDetail.tsx:755 msgid "Undo" msgstr "Undo" @@ -1492,12 +1468,12 @@ msgstr "Unfollow playlist" msgid "Unmute" msgstr "Unmute" -#: src/pages/UserPublicProfile.tsx:807 +#: src/components/profile/ProfileHeader.tsx:70 msgid "Upload failed" msgstr "Upload failed" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:484 +#: src/components/dump/create/DumpCreateModal.tsx:448 msgid "Uploading {0}%" msgstr "Uploading {0}%" @@ -1505,7 +1481,7 @@ msgstr "Uploading {0}%" msgid "Uploading image…" msgstr "Uploading image…" -#: src/components/dump/create/DumpCreateModal.tsx:797 +#: src/components/dump/create/DumpCreateModal.tsx:760 msgid "Uploading…" msgstr "Uploading…" @@ -1513,13 +1489,13 @@ msgstr "Uploading…" msgid "Upvote" msgstr "Upvote" -#: src/pages/UserUpvoted.tsx:186 +#: src/pages/UserUpvoted.tsx:74 msgid "Upvoted" msgstr "Upvoted" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1057 +#: src/components/profile/ProfileDumpsTab.tsx:51 msgid "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})" @@ -1527,7 +1503,7 @@ msgstr "Upvoted ({0}{1})" msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:401 +#: src/components/dump/create/DumpCreateModal.tsx:397 msgid "URL is required." msgstr "URL is required." @@ -1535,11 +1511,11 @@ msgstr "URL is required." msgid "URL slug" msgstr "URL slug" -#: src/pages/UserPublicProfile.tsx:1724 +#: src/components/profile/RoleSwitcher.tsx:22 msgid "User" msgstr "User" -#: src/pages/UserPublicProfile.tsx:1442 +#: src/components/profile/ProfileManageTab.tsx:38 msgid "User management" msgstr "User management" @@ -1552,18 +1528,15 @@ msgstr "User menu" msgid "Username" msgstr "Username" -#: src/pages/Search.tsx:199 +#: src/pages/Search.tsx:198 msgid "Users" msgstr "Users" -#: src/pages/UserPublicProfile.tsx:1127 -#: src/pages/UserPublicProfile.tsx:1216 -#: src/pages/UserPublicProfile.tsx:1537 -#: src/pages/UserPublicProfile.tsx:1669 +#: src/components/profile/ProfileSection.tsx:43 msgid "View all →" msgstr "View all →" -#: src/components/dump/create/DumpCreateModal.tsx:561 +#: src/components/dump/create/DumpCreateModal.tsx:525 msgid "View dump →" msgstr "View dump →" @@ -1571,17 +1544,17 @@ msgstr "View dump →" msgid "Volume" msgstr "Volume" -#: src/components/dump/create/DumpCreateModal.tsx:748 +#: src/components/dump/create/DumpCreateModal.tsx:711 #: src/pages/DumpEdit.tsx:515 msgid "What makes it worth it?" msgstr "What makes it worth it?" -#: src/pages/UserPublicProfile.tsx:1011 -#: src/pages/UserPublicProfile.tsx:1923 +#: src/components/profile/DescriptionEditor.tsx:77 +#: src/components/profile/ProfileDescription.tsx:79 msgid "Who am I?" msgstr "Who am I?" -#: src/components/dump/create/DumpCreateModal.tsx:763 +#: src/components/dump/create/DumpCreateModal.tsx:726 msgid "Who can see it" msgstr "Who can see it" @@ -1589,7 +1562,7 @@ msgstr "Who can see it" msgid "Why & where" msgstr "Why & where" -#: src/components/dump/create/DumpCreateModal.tsx:747 +#: src/components/dump/create/DumpCreateModal.tsx:710 #: src/pages/DumpEdit.tsx:514 msgid "Why?" msgstr "Why?" @@ -1598,7 +1571,7 @@ msgstr "Why?" msgid "Write a reply…" msgstr "Write a reply…" -#: src/pages/Notifications.tsx:471 +#: src/pages/Notifications.tsx:472 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Yesterday" @@ -1607,7 +1580,7 @@ msgstr "Yesterday" msgid "You can post it anyway." msgstr "You can post it anyway." -#: src/pages/Notifications.tsx:456 +#: src/pages/Notifications.tsx:457 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." @@ -1615,14 +1588,7 @@ msgstr "You'll be notified when someone follows your playlists, upvotes your dum msgid "You're offline — not sent" msgstr "You're offline — not sent" -#: src/components/feed/DumpFeed.tsx:120 -#: src/components/feed/FollowedFeed.tsx:136 -#: src/components/feed/JournalFeed.tsx:91 -#: src/pages/Search.tsx:272 -#: src/pages/UserDumps.tsx:136 -#: src/pages/UserPlaylists.tsx:452 -#: src/pages/UserPlaylists.tsx:487 -#: src/pages/UserUpvoted.tsx:227 +#: src/components/feed/FeedFooter.tsx:39 msgid "You've reached the end." msgstr "You've reached the end." diff --git a/src/locales/fr.po b/src/locales/fr.po index 1ecef0a6..8b8f85c6 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -55,7 +55,7 @@ msgstr "{0} de plus en ligne : {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" -#: src/pages/Search.tsx:201 +#: src/pages/Search.tsx:200 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -67,9 +67,9 @@ msgstr "{unseenCount} nouveaux messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" -#: src/components/dump/create/DumpCreateModal.tsx:778 -#: src/pages/PlaylistDetail.tsx:630 -#: src/pages/UserPublicProfile.tsx:853 +#: src/components/dump/create/DumpCreateModal.tsx:741 +#: src/pages/PlaylistDetail.tsx:580 +#: src/pages/UserPublicProfile.tsx:167 msgid "← Back" msgstr "← Retour" @@ -79,13 +79,13 @@ msgstr "← Retour" msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" -#: src/pages/UserDumps.tsx:76 -#: src/pages/UserPlaylists.tsx:380 -#: src/pages/UserUpvoted.tsx:169 +#: src/pages/UserDumps.tsx:74 +#: src/pages/UserPlaylists.tsx:306 +#: src/pages/UserUpvoted.tsx:60 msgid "← Back to profile" msgstr "← Retour au profil" -#: src/pages/UserPublicProfile.tsx:142 +#: src/components/profile/ProfileOwnActions.tsx:80 msgid "+ Invite someone" msgstr "+ Inviter quelqu'un" @@ -99,47 +99,47 @@ msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:211 +#: src/pages/Notifications.tsx:212 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} a commenté sur <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:171 +#: src/pages/Notifications.tsx:172 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} a suivi votre collection <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:221 +#: src/pages/Notifications.tsx:222 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} a aimé votre commentaire sur <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:237 +#: src/pages/Notifications.tsx:238 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} vous a mentionné dans <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:181 +#: src/pages/Notifications.tsx:182 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:162 +#: src/pages/Notifications.tsx:163 msgid "<0>{0} started following you" msgstr "<0>{0} a commencé à vous suivre" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:201 +#: src/pages/Notifications.tsx:202 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} a voté pour <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:191 +#: src/pages/Notifications.tsx:192 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" @@ -147,15 +147,15 @@ msgstr "<0>{0} a été ajouté à <1>{1}" msgid "1 new message" msgstr "1 nouveau message" -#: src/pages/Notifications.tsx:232 +#: src/pages/Notifications.tsx:233 msgid "a comment" msgstr "un commentaire" -#: src/pages/Notifications.tsx:235 +#: src/pages/Notifications.tsx:236 msgid "a post" msgstr "une publication" -#: src/pages/UserPublicProfile.tsx:1261 +#: src/components/profile/ProfileSettingsTab.tsx:66 msgid "Account" msgstr "Compte" @@ -168,7 +168,7 @@ msgstr "Ajouter un commentaire…" msgid "Add category" msgstr "Ajouter une catégorie" -#: src/pages/UserPublicProfile.tsx:939 +#: src/components/profile/ProfileHeader.tsx:152 msgid "Add email…" msgstr "Ajouter un e-mail…" @@ -176,12 +176,12 @@ msgstr "Ajouter un e-mail…" msgid "Add image" msgstr "Ajouter une image" -#: src/components/dump/create/DumpCreateModal.tsx:500 -#: src/components/playlist/AddToPlaylistModal.tsx:72 +#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/playlist/AddToPlaylistModal.tsx:29 msgid "Add to playlist" msgstr "Ajouter à la collection" -#: src/pages/UserPublicProfile.tsx:1726 +#: src/components/profile/RoleSwitcher.tsx:24 msgid "Admin" msgstr "Administrateur" @@ -204,7 +204,7 @@ msgstr "Vous avez déjà un compte ? <0>Se connecter" msgid "An unexpected error occurred." msgstr "Une erreur inattendue s'est produite." -#: src/pages/UserPublicProfile.tsx:1280 +#: src/components/profile/ProfileSettingsTab.tsx:79 msgid "Appearance" msgstr "Apparence" @@ -219,7 +219,7 @@ msgstr "Au moins {0} caractères" msgid "At most {maxLength} characters." msgstr "{maxLength} caractères au maximum." -#: src/pages/UserPublicProfile.tsx:1342 +#: src/components/profile/ProfileSettingsTab.tsx:35 msgid "Auto" msgstr "Auto" @@ -237,18 +237,18 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo #: src/components/category/CategoryManager.tsx:226 #: src/components/chat/ChatEditBox.tsx:88 #: src/components/comment/CommentForm.tsx:127 -#: src/components/dump/create/DumpCreateModal.tsx:788 +#: src/components/dump/create/DumpCreateModal.tsx:751 #: src/components/form/FormActions.tsx:42 +#: src/components/profile/DescriptionEditor.tsx:96 +#: 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:1014 -#: src/pages/UserPublicProfile.tsx:1863 -#: src/pages/UserPublicProfile.tsx:1942 +#: src/pages/PlaylistDetail.tsx:948 msgid "Cancel" msgstr "Annuler" -#: src/pages/PlaylistDetail.tsx:808 +#: src/pages/PlaylistDetail.tsx:753 msgid "Cancel removal" msgstr "Annuler la suppression" @@ -262,7 +262,7 @@ msgid "Cancel search" msgstr "Annuler la recherche" #: src/components/category/CategorySelect.tsx:33 -#: src/pages/UserPublicProfile.tsx:1457 +#: src/components/profile/ProfileManageTab.tsx:49 msgid "Categories" msgstr "Catégories" @@ -270,7 +270,7 @@ msgstr "Catégories" msgid "Category" msgstr "Catégorie" -#: src/pages/UserPublicProfile.tsx:880 +#: src/components/profile/ProfileHeader.tsx:93 msgid "Change avatar" msgstr "Changer l'avatar" @@ -283,7 +283,7 @@ msgstr "Changer l'image" msgid "Change password" msgstr "Changer le mot de passe" -#: src/pages/UserPublicProfile.tsx:1273 +#: src/components/profile/ProfileSettingsTab.tsx:74 msgid "Change password…" msgstr "Changer le mot de passe…" @@ -312,7 +312,7 @@ msgstr "Vérification de l'invitation…" msgid "Close" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:531 +#: src/components/dump/create/DumpCreateModal.tsx:495 msgid "Close anyway" msgstr "Fermer quand même" @@ -324,7 +324,7 @@ msgstr "Fermer le sélecteur d'emoji" msgid "Close player" msgstr "Fermer le lecteur" -#: src/components/dump/create/DumpCreateModal.tsx:521 +#: src/components/dump/create/DumpCreateModal.tsx:485 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé." @@ -332,7 +332,7 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez msgid "Collapse player" msgstr "Réduire le lecteur" -#: src/pages/UserPublicProfile.tsx:1334 +#: src/components/profile/ProfileSettingsTab.tsx:88 msgid "Color scheme" msgstr "Thème de couleur" @@ -345,11 +345,11 @@ msgstr "Confirmer le nouveau mot de passe" msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" -#: src/pages/UserPublicProfile.tsx:167 +#: src/components/profile/ProfileOwnActions.tsx:105 msgid "Copied!" msgstr "Copié !" -#: src/pages/UserPublicProfile.tsx:167 +#: src/components/profile/ProfileOwnActions.tsx:105 msgid "Copy" msgstr "Copier" @@ -362,11 +362,11 @@ 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:412 +#: src/pages/UserPlaylists.tsx:330 msgid "Could not delete" msgstr "Suppression impossible" -#: src/components/social/UserListPopover.tsx:166 +#: src/components/social/UserListPopover.tsx:167 msgid "Could not load." msgstr "Impossible de charger." @@ -374,7 +374,7 @@ msgstr "Impossible de charger." msgid "Could not save" msgstr "Sauvegarde impossible" -#: src/components/dump/create/DumpCreateModal.tsx:643 +#: src/components/dump/create/DumpCreateModal.tsx:606 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard." @@ -396,7 +396,7 @@ msgstr "Créer et ajouter" #. placeholder {0}: created.items.length #. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:418 +#: src/pages/UserPlaylists.tsx:335 msgid "Created ({0}{1})" msgstr "Créées ({0}{1})" @@ -408,11 +408,11 @@ msgstr "Création…" msgid "Current password" msgstr "Mot de passe actuel" -#: src/pages/UserPublicProfile.tsx:1356 +#: src/components/profile/ProfileSettingsTab.tsx:37 msgid "Dark" msgstr "Sombre" -#: src/pages/UserPublicProfile.tsx:1369 +#: src/components/profile/ProfileSettingsTab.tsx:99 msgid "Default tab" msgstr "Onglet par défaut" @@ -424,7 +424,7 @@ msgstr "Onglet par défaut" #: src/components/comment/CommentNode.tsx:263 #: src/components/comment/CommentNode.tsx:269 #: src/components/shared/ConfirmModal.tsx:34 -#: src/pages/PlaylistDetail.tsx:1010 +#: src/pages/PlaylistDetail.tsx:944 msgid "Delete" msgstr "Supprimer" @@ -448,8 +448,8 @@ msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" #: src/components/playlist/PlaylistCard.tsx:132 -#: src/pages/PlaylistDetail.tsx:821 -#: src/pages/UserPlaylists.tsx:495 +#: src/pages/PlaylistDetail.tsx:766 +#: src/pages/UserPlaylists.tsx:393 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -465,8 +465,8 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/pages/PlaylistDetail.tsx:820 -#: src/pages/UserPlaylists.tsx:494 +#: src/pages/PlaylistDetail.tsx:765 +#: src/pages/UserPlaylists.tsx:392 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -475,7 +475,7 @@ msgid "deleted message" msgstr "message supprimé" #: src/components/playlist/PlaylistCreateForm.tsx:104 -#: src/pages/PlaylistDetail.tsx:971 +#: src/pages/PlaylistDetail.tsx:916 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -492,7 +492,7 @@ msgstr "Déconnecté — reconnexion…" msgid "Dismiss" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:582 +#: src/components/dump/create/DumpCreateModal.tsx:545 msgid "Done" msgstr "Terminé" @@ -505,7 +505,7 @@ msgstr "Télécharger {0}" msgid "Drop a file here" msgstr "Déposez un fichier ici" -#: src/components/dump/create/DumpCreateModal.tsx:689 +#: src/components/dump/create/DumpCreateModal.tsx:652 msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" @@ -517,27 +517,27 @@ msgstr "Déposez un fichier de remplacement ici" msgid "Dump" msgstr "Reco" -#: src/components/dump/create/DumpCreateModal.tsx:801 +#: src/components/dump/create/DumpCreateModal.tsx:764 msgid "Dump it" msgstr "Recommander" -#: src/components/dump/create/DumpCreateModal.tsx:559 +#: src/components/dump/create/DumpCreateModal.tsx:523 msgid "Dumped!" msgstr "Recommandé !" -#: src/pages/Search.tsx:197 -#: src/pages/UserDumps.tsx:90 -#: src/pages/UserPublicProfile.tsx:1026 +#: src/pages/Search.tsx:196 +#: src/pages/UserDumps.tsx:88 +#: src/pages/UserPublicProfile.tsx:197 msgid "Dumps" msgstr "Recos" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1046 +#: src/components/profile/ProfileDumpsTab.tsx:36 msgid "Dumps ({0}{1})" msgstr "Recos ({0}{1})" -#: src/pages/Notifications.tsx:472 +#: src/pages/Notifications.tsx:473 msgid "Earlier" msgstr "Plus tôt" @@ -546,7 +546,7 @@ msgstr "Plus tôt" #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:254 #: src/pages/Dump.tsx:550 -#: src/pages/PlaylistDetail.tsx:684 +#: src/pages/PlaylistDetail.tsx:634 msgid "Edit" msgstr "Modifier" @@ -555,11 +555,11 @@ msgstr "Modifier" msgid "Edit {0}" msgstr "Modifier {0}" -#: src/pages/UserPublicProfile.tsx:990 +#: src/components/profile/ProfileDescription.tsx:58 msgid "Edit description" msgstr "Modifier la description" -#: src/pages/UserPublicProfile.tsx:937 +#: src/components/profile/ProfileHeader.tsx:150 msgid "Edit email" msgstr "Modifier l'e-mail" @@ -578,7 +578,7 @@ msgstr "modifié" #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 #: src/pages/Dump.tsx:491 -#: src/pages/PlaylistDetail.tsx:723 +#: src/pages/PlaylistDetail.tsx:673 msgid "edited {0}" msgstr "modifié {0}" @@ -588,7 +588,7 @@ msgstr "modifié {0}" #: src/components/chat/ChatMessageItem.tsx:124 #: src/components/comment/CommentNode.tsx:201 #: src/pages/Dump.tsx:489 -#: src/pages/PlaylistDetail.tsx:720 +#: src/pages/PlaylistDetail.tsx:670 msgid "Edited {0}" msgstr "Modifié le {0}" @@ -605,7 +605,7 @@ msgstr "Édition de {0}" msgid "Email address" msgstr "Adresse e-mail" -#: src/pages/Search.tsx:230 +#: src/pages/Search.tsx:229 msgid "Enter a query to search." msgstr "Saisissez une recherche." @@ -625,23 +625,18 @@ msgstr "Impossible de créer la collection" msgid "Failed to delete" msgstr "Suppression échouée" -#: src/pages/UserPublicProfile.tsx:111 -#: src/pages/UserPublicProfile.tsx:114 -#: src/pages/UserPublicProfile.tsx:176 +#: src/components/profile/ProfileOwnActions.tsx:49 +#: src/components/profile/ProfileOwnActions.tsx:52 +#: src/components/profile/ProfileOwnActions.tsx:114 msgid "Failed to generate invite" msgstr "Impossible de générer une invitation" -#: src/components/feed/DumpFeed.tsx:87 -#: src/components/feed/FollowedFeed.tsx:105 -#: src/components/feed/JournalFeed.tsx:45 -#: src/pages/Notifications.tsx:446 -#: src/pages/UserPublicProfile.tsx:1148 -#: src/pages/UserPublicProfile.tsx:1194 -#: src/pages/UserPublicProfile.tsx:1239 +#: src/components/shared/ListState.tsx:43 +#: src/pages/Notifications.tsx:447 msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/dump/create/DumpCreateModal.tsx:591 +#: src/components/dump/create/DumpCreateModal.tsx:554 msgid "Failed to post" msgstr "Publication échouée" @@ -653,9 +648,9 @@ msgstr "Impossible de publier le commentaire" msgid "Failed to post reply" msgstr "Impossible de publier la réponse" -#: src/pages/PlaylistDetail.tsx:1002 -#: src/pages/UserPublicProfile.tsx:1866 -#: src/pages/UserPublicProfile.tsx:1944 +#: src/components/profile/DescriptionEditor.tsx:98 +#: src/components/profile/EmailEditor.tsx:86 +#: src/pages/PlaylistDetail.tsx:936 msgid "Failed to save" msgstr "Enregistrement échoué" @@ -667,23 +662,23 @@ msgstr "Échec de l’enregistrement de la catégorie" msgid "Failed to save edit" msgstr "Impossible d'enregistrer la modification" -#: src/pages/UserPublicProfile.tsx:948 +#: src/components/profile/ProfileHeader.tsx:161 msgid "Failed to update avatar" msgstr "Impossible de mettre à jour l'avatar" -#: src/pages/UserPublicProfile.tsx:1800 +#: src/components/profile/RoleSwitcher.tsx:92 msgid "Failed to update role" msgstr "Erreur lors de la mise à jour du rôle" -#: src/pages/UserPublicProfile.tsx:1364 +#: src/components/profile/ProfileSettingsTab.tsx:97 msgid "Feeds" msgstr "Flux" -#: src/components/dump/create/DumpCreateModal.tsx:630 +#: src/components/dump/create/DumpCreateModal.tsx:593 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/dump/create/DumpCreateModal.tsx:420 +#: src/components/dump/create/DumpCreateModal.tsx:416 msgid "File too large (max {max})." msgstr "Fichier trop volumineux (max {max})." @@ -700,33 +695,33 @@ msgstr "Suivre {targetUsername}" msgid "Follow playlist" msgstr "Suivre la collection" -#: src/components/feed/FollowedFeed.tsx:388 +#: src/components/feed/FollowedFeed.tsx:215 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:375 +#: src/components/feed/FollowedFeed.tsx:207 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/pages/UserPublicProfile.tsx:1028 -#: src/pages/UserPublicProfile.tsx:1398 +#: src/components/profile/ProfileSettingsTab.tsx:44 +#: src/pages/UserPublicProfile.tsx:199 msgid "Followed" msgstr "Suivi" #. placeholder {0}: followed.items.length #. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:460 +#: src/pages/UserPlaylists.tsx:366 msgid "Followed ({0}{1})" msgstr "Suivies ({0}{1})" -#: src/pages/UserPublicProfile.tsx:1183 +#: src/components/profile/ProfileFollowedTab.tsx:100 msgid "Followed playlists" msgstr "Collections suivies" +#: src/components/profile/ProfileFollowedTab.tsx:68 #: src/components/social/FollowButton.tsx:62 #: src/components/social/FollowButton.tsx:102 -#: src/pages/UserPublicProfile.tsx:1137 msgid "Following" msgstr "Abonné" @@ -734,11 +729,11 @@ msgstr "Abonné" msgid "Forgot password?" msgstr "Mot de passe oublié ?" -#: src/components/feed/FollowedFeed.tsx:359 +#: src/components/feed/FollowedFeed.tsx:196 msgid "From people" msgstr "De personnes" -#: src/components/feed/FollowedFeed.tsx:360 +#: src/components/feed/FollowedFeed.tsx:197 msgid "From playlists" msgstr "De collections" @@ -755,7 +750,7 @@ msgid "Go to login" msgstr "Aller à la connexion" #: src/components/feed/FeedTabBar.tsx:44 -#: src/pages/UserPublicProfile.tsx:1377 +#: src/components/profile/ProfileSettingsTab.tsx:41 msgid "Hot" msgstr "Tendances" @@ -767,7 +762,7 @@ msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialis msgid "image" msgstr "image" -#: src/pages/Dump.tsx:582 +#: src/pages/Dump.tsx:570 msgid "In collections" msgstr "Dans les collections" @@ -784,30 +779,30 @@ msgstr "Invitation invalide" msgid "Invalid link" msgstr "Lien invalide" -#: src/pages/UserPublicProfile.tsx:150 -#: src/pages/UserPublicProfile.tsx:157 +#: src/components/profile/ProfileOwnActions.tsx:88 +#: src/components/profile/ProfileOwnActions.tsx:95 msgid "Invite link" msgstr "Lien d'invitation" -#: src/pages/UserPublicProfile.tsx:902 +#: src/components/profile/ProfileHeader.tsx:115 msgid "invited by" msgstr "invité par" -#: src/pages/UserPublicProfile.tsx:1029 -#: src/pages/UserPublicProfile.tsx:1228 +#: src/components/profile/ProfileInviteesTab.tsx:76 +#: src/pages/UserPublicProfile.tsx:200 msgid "Invitees" msgstr "Invités" #: src/components/feed/FeedTabBar.tsx:46 -#: src/pages/UserPublicProfile.tsx:1391 +#: src/components/profile/ProfileSettingsTab.tsx:43 msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:528 +#: src/components/dump/create/DumpCreateModal.tsx:492 msgid "Keep editing" msgstr "Continuer l'édition" -#: src/pages/UserPublicProfile.tsx:1349 +#: src/components/profile/ProfileSettingsTab.tsx:36 msgid "Light" msgstr "Clair" @@ -815,7 +810,7 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/components/dump/create/DumpCreateModal.tsx:605 +#: src/components/dump/create/DumpCreateModal.tsx:568 msgid "Link" msgstr "Lien" @@ -831,9 +826,7 @@ msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." -#: src/components/social/UserListPopover.tsx:262 -#: src/pages/Notifications.tsx:519 -#: src/pages/UserPublicProfile.tsx:1174 +#: src/components/shared/LoadMoreButton.tsx:32 msgid "Load more" msgstr "Charger plus" @@ -850,43 +843,29 @@ msgstr "Chargement de la reco…" msgid "Loading messages…" msgstr "Chargement des messages…" -#: src/components/feed/DumpFeed.tsx:115 -#: src/components/feed/FollowedFeed.tsx:131 -#: src/components/feed/JournalFeed.tsx:86 -#: src/pages/Search.tsx:267 -#: src/pages/UserDumps.tsx:131 -#: src/pages/UserPlaylists.tsx:447 -#: src/pages/UserPlaylists.tsx:482 -#: src/pages/UserUpvoted.tsx:222 +#: src/components/feed/FeedFooter.tsx:34 msgid "Loading more…" msgstr "Chargement…" -#: src/pages/PlaylistDetail.tsx:614 +#: src/pages/PlaylistDetail.tsx:564 msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:836 +#: src/pages/UserPublicProfile.tsx:150 msgid "Loading profile…" msgstr "Chargement du profil…" #: src/components/category/CategoryManager.tsx:60 #: src/components/chat/ChatModal.tsx:740 #: src/components/editor/EmojiPickerPopup.tsx:125 -#: src/components/feed/DumpFeed.tsx:83 -#: src/components/feed/FollowedFeed.tsx:100 -#: src/components/feed/JournalFeed.tsx:41 #: src/components/playlist/PlaylistMembershipPanel.tsx:45 -#: src/components/social/UserListPopover.tsx:223 -#: src/components/social/UserListPopover.tsx:261 -#: src/pages/Notifications.tsx:442 -#: src/pages/Notifications.tsx:518 -#: src/pages/UserDumps.tsx:64 -#: src/pages/UserPlaylists.tsx:368 -#: src/pages/UserPublicProfile.tsx:1142 -#: src/pages/UserPublicProfile.tsx:1173 -#: src/pages/UserPublicProfile.tsx:1188 -#: src/pages/UserPublicProfile.tsx:1233 -#: src/pages/UserUpvoted.tsx:157 +#: src/components/shared/ListState.tsx:38 +#: src/components/shared/LoadMoreButton.tsx:32 +#: src/components/social/UserListPopover.tsx:224 +#: src/pages/Notifications.tsx:443 +#: src/pages/UserDumps.tsx:62 +#: src/pages/UserPlaylists.tsx:294 +#: src/pages/UserUpvoted.tsx:48 msgid "Loading…" msgstr "Chargement…" @@ -905,8 +884,8 @@ msgstr "Se connecter pour aimer" msgid "Log in to vote" msgstr "Se connecter pour voter" -#: src/pages/UserPublicProfile.tsx:857 -#: src/pages/UserPublicProfile.tsx:961 +#: src/components/profile/ProfileHeader.tsx:174 +#: src/pages/UserPublicProfile.tsx:171 msgid "Log out" msgstr "Se déconnecter" @@ -918,7 +897,7 @@ msgstr "Connexion…" msgid "Login failed" msgstr "Connexion échouée" -#: src/pages/UserPublicProfile.tsx:1034 +#: src/pages/UserPublicProfile.tsx:205 msgid "Manage" msgstr "Administration" @@ -926,7 +905,7 @@ msgstr "Administration" msgid "Max {limit}" msgstr "Max {limit}" -#: src/pages/UserPublicProfile.tsx:1725 +#: src/components/profile/RoleSwitcher.tsx:23 msgid "Moderator" msgstr "Modérateur" @@ -939,21 +918,20 @@ msgstr "Couper le son" msgid "Name" msgstr "Nom" -#: src/pages/Notifications.tsx:435 +#: src/pages/Notifications.tsx:436 msgid "new" msgstr "nouveau" #: src/components/chat/ChatModal.tsx:669 #: src/components/feed/FeedTabBar.tsx:45 -#: src/pages/UserPublicProfile.tsx:1384 +#: src/components/profile/ProfileSettingsTab.tsx:42 msgid "New" msgstr "Nouveau" -#: src/components/dump/create/DumpCreateModal.tsx:500 +#: src/components/dump/create/DumpCreateModal.tsx:464 +#: src/components/dump/NewDumpButton.tsx:27 #: src/components/layout/DumpFab.tsx:66 #: src/components/layout/DumpFab.tsx:67 -#: src/pages/UserDumps.tsx:98 -#: src/pages/UserPublicProfile.tsx:1505 msgid "New dump" msgstr "Nouvelle reco" @@ -967,7 +945,7 @@ msgstr "Nouveau mot de passe" msgid "New playlist" msgstr "Nouvelle collection" -#: src/components/dump/create/DumpCreateModal.tsx:800 +#: src/components/dump/create/DumpCreateModal.tsx:763 msgid "Next →" msgstr "Suivant →" @@ -975,16 +953,16 @@ msgstr "Suivant →" msgid "Next track" msgstr "Piste suivante" -#: src/pages/PlaylistDetail.tsx:743 +#: src/pages/PlaylistDetail.tsx:693 msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." -#: src/pages/Search.tsx:247 +#: src/pages/Search.tsx:246 msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." -#: src/components/feed/DumpFeed.tsx:91 -#: src/components/feed/JournalFeed.tsx:49 +#: src/components/feed/DumpFeed.tsx:74 +#: src/components/feed/JournalFeed.tsx:36 msgid "No dumps yet. Be the first!" msgstr "Pas encore de recos. Soyez le premier !" @@ -992,12 +970,12 @@ msgstr "Pas encore de recos. Soyez le premier !" msgid "No emoji found." msgstr "Aucun emoji trouvé." -#: src/pages/UserPlaylists.tsx:469 -#: src/pages/UserPublicProfile.tsx:1201 +#: src/components/profile/ProfileFollowedTab.tsx:110 +#: src/pages/UserPlaylists.tsx:375 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." -#: src/pages/UserPublicProfile.tsx:1246 +#: src/components/profile/ProfileInviteesTab.tsx:82 msgid "No invitees yet." msgstr "Aucun invité pour le moment." @@ -1005,17 +983,17 @@ 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:231 +#: src/components/social/UserListPopover.tsx:232 msgid "No one yet." msgstr "Personne pour le moment." -#: src/pages/Search.tsx:306 +#: src/pages/Search.tsx:288 msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/pages/UserPlaylists.tsx:427 -#: src/pages/UserPublicProfile.tsx:1108 +#: src/components/profile/ProfilePlaylistsTab.tsx:51 +#: src/pages/UserPlaylists.tsx:344 msgid "No playlists yet." msgstr "Pas encore de collections." @@ -1023,26 +1001,26 @@ msgstr "Pas encore de collections." 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:280 +#: src/pages/Search.tsx:262 msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." -#: src/pages/UserPublicProfile.tsx:1155 +#: src/components/profile/ProfileFollowedTab.tsx:74 msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." -#: src/pages/Notifications.tsx:453 -#: src/pages/UserDumps.tsx:109 -#: src/pages/UserPublicProfile.tsx:1516 -#: src/pages/UserPublicProfile.tsx:1639 -#: src/pages/UserUpvoted.tsx:191 +#: src/components/profile/ProfileDumpsTab.tsx:45 +#: src/components/profile/ProfileDumpsTab.tsx:59 +#: src/pages/Notifications.tsx:454 +#: src/pages/UserDumps.tsx:94 +#: src/pages/UserUpvoted.tsx:79 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/layout/NotificationBell.tsx:54 -#: src/pages/Notifications.tsx:294 -#: src/pages/Notifications.tsx:431 -#: src/pages/UserPublicProfile.tsx:1406 +#: src/components/profile/ProfileSettingsTab.tsx:108 +#: src/pages/Notifications.tsx:295 +#: src/pages/Notifications.tsx:432 msgid "Notifications" msgstr "Notifications" @@ -1050,11 +1028,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" -#: src/pages/UserPublicProfile.tsx:1430 +#: src/components/profile/ProfileSettingsTab.tsx:49 msgid "Off" msgstr "Désactivés" -#: src/pages/UserPublicProfile.tsx:1423 +#: src/components/profile/ProfileSettingsTab.tsx:48 msgid "On" msgstr "Activés" @@ -1083,8 +1061,8 @@ msgstr "ou <0>parcourir les fichiers" msgid "Page not found" msgstr "Page introuvable" +#: src/components/profile/ProfileSettingsTab.tsx:68 #: src/pages/UserLogin.tsx:82 -#: src/pages/UserPublicProfile.tsx:1266 msgid "Password" msgstr "Mot de passe" @@ -1106,7 +1084,7 @@ msgstr "Mot de passe mis à jour" msgid "Passwords do not match" msgstr "Les mots de passe ne correspondent pas" -#: src/components/dump/create/DumpCreateModal.tsx:623 +#: src/components/dump/create/DumpCreateModal.tsx:586 msgid "Paste a link…" msgstr "Collez un lien…" @@ -1117,7 +1095,7 @@ msgstr "Collez un lien…" msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:540 +#: src/components/dump/create/DumpCreateModal.tsx:504 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." @@ -1131,22 +1109,22 @@ msgstr "Reprise de votre brouillon." msgid "Play" msgstr "Lecture" -#: src/pages/PlaylistDetail.tsx:955 +#: src/pages/PlaylistDetail.tsx:900 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:200 -#: src/pages/UserPlaylists.tsx:394 -#: src/pages/UserPublicProfile.tsx:1027 +#: src/pages/Search.tsx:199 +#: src/pages/UserPlaylists.tsx:320 +#: src/pages/UserPublicProfile.tsx:198 msgid "Playlists" msgstr "Collections" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1077 +#: src/components/profile/ProfilePlaylistsTab.tsx:34 msgid "Playlists ({0}{1})" msgstr "Collections ({0}{1})" @@ -1160,7 +1138,7 @@ msgstr "Publier la réponse" #: src/components/comment/CommentNode.tsx:288 #: src/components/comment/CommentThread.tsx:100 -#: src/components/dump/create/DumpCreateModal.tsx:794 +#: src/components/dump/create/DumpCreateModal.tsx:757 msgid "Posting…" msgstr "Publication…" @@ -1173,22 +1151,20 @@ msgstr "Piste précédente" #: src/components/playlist/PlaylistCard.tsx:95 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/pages/Dump.tsx:497 -#: src/pages/PlaylistDetail.tsx:703 +#: src/pages/PlaylistDetail.tsx:653 msgid "private" msgstr "privé" -#: src/components/form/SegmentedField.tsx:100 -#: src/pages/PlaylistDetail.tsx:996 +#: src/components/form/SegmentedField.tsx:65 msgid "Private" msgstr "Privé" #: src/components/playlist/PlaylistCard.tsx:94 -#: src/pages/PlaylistDetail.tsx:702 +#: src/pages/PlaylistDetail.tsx:652 msgid "public" msgstr "public" -#: src/components/form/SegmentedField.tsx:99 -#: src/pages/PlaylistDetail.tsx:989 +#: src/components/form/SegmentedField.tsx:64 msgid "Public" msgstr "Public" @@ -1226,7 +1202,7 @@ msgstr "Connexe" msgid "Remove file" msgstr "Supprimer le fichier" -#: src/pages/PlaylistDetail.tsx:798 +#: src/pages/PlaylistDetail.tsx:743 msgid "Remove from playlist" msgstr "Retirer de la collection" @@ -1280,28 +1256,28 @@ msgstr "Rétablir le titre suggéré" msgid "Retry" msgstr "Réessayer" -#: src/pages/UserPublicProfile.tsx:1784 +#: src/components/profile/RoleSwitcher.tsx:84 msgid "Role" 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/profile/EmailEditor.tsx:75 #: src/pages/Dump.tsx:424 #: src/pages/DumpEdit.tsx:545 -#: src/pages/PlaylistDetail.tsx:1021 -#: src/pages/UserPublicProfile.tsx:1855 -#: src/pages/UserPublicProfile.tsx:1934 +#: src/pages/PlaylistDetail.tsx:955 msgid "Save" msgstr "Enregistrer" #: src/components/comment/CommentNode.tsx:215 #: src/components/profile/ChangePasswordModal.tsx:120 +#: src/components/profile/DescriptionEditor.tsx:85 +#: src/components/profile/EmailEditor.tsx:72 #: src/pages/Dump.tsx:423 -#: src/pages/PlaylistDetail.tsx:1017 +#: src/pages/PlaylistDetail.tsx:951 #: src/pages/ResetPassword.tsx:136 -#: src/pages/UserPublicProfile.tsx:1852 -#: src/pages/UserPublicProfile.tsx:1931 msgid "Saving…" msgstr "Enregistrement…" @@ -1323,11 +1299,11 @@ msgstr "Rechercher un emoji" msgid "Search emoji…" msgstr "Rechercher un emoji…" -#: src/pages/Search.tsx:241 +#: src/pages/Search.tsx:240 msgid "Search failed" msgstr "Recherche échouée" -#: src/pages/Search.tsx:236 +#: src/pages/Search.tsx:235 msgid "Searching…" msgstr "Recherche…" @@ -1357,7 +1333,7 @@ msgstr "Serveur inaccessible" msgid "Set new password" msgstr "Définir un nouveau mot de passe" -#: src/pages/UserPublicProfile.tsx:1031 +#: src/pages/UserPublicProfile.tsx:202 msgid "Settings" msgstr "Paramètres" @@ -1365,7 +1341,7 @@ msgstr "Paramètres" msgid "Several people are typing…" msgstr "Plusieurs personnes écrivent…" -#: src/pages/UserPublicProfile.tsx:170 +#: src/components/profile/ProfileOwnActions.tsx:108 msgid "Single use, valid for 7 days." msgstr "Usage unique, valable 7 jours." @@ -1380,19 +1356,19 @@ 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:737 +#: src/pages/PlaylistDetail.tsx:687 msgid "Something went wrong" msgstr "Une erreur est survenue" -#: src/pages/UserPublicProfile.tsx:1411 +#: src/components/profile/ProfileSettingsTab.tsx:110 msgid "Sounds" msgstr "Sons" -#: src/components/dump/create/DumpCreateModal.tsx:547 +#: src/components/dump/create/DumpCreateModal.tsx:511 msgid "Start fresh" msgstr "Repartir de zéro" -#: src/pages/UserPublicProfile.tsx:1285 +#: src/components/profile/ProfileSettingsTab.tsx:81 msgid "Style" msgstr "Style" @@ -1404,15 +1380,15 @@ msgstr "Lancer la recherche" msgid "That file is over {limit}." msgstr "Ce fichier dépasse {limit}." -#: src/pages/Notifications.tsx:234 +#: src/pages/Notifications.tsx:235 msgid "the chat" msgstr "la tribune" -#: src/pages/PlaylistDetail.tsx:563 +#: 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:511 +#: src/pages/PlaylistDetail.tsx:461 msgid "The dump couldn't be removed. Try again." msgstr "Le dump n'a pas pu être retiré. Réessayez." @@ -1450,11 +1426,11 @@ msgstr "Miniature" msgid "Title" msgstr "Titre" -#: src/components/dump/create/DumpCreateModal.tsx:417 +#: src/components/dump/create/DumpCreateModal.tsx:413 msgid "Title is required." msgstr "Un titre est requis." -#: src/pages/Notifications.tsx:469 +#: src/pages/Notifications.tsx:470 #: src/utils/chat/time.ts:24 msgid "Today" msgstr "Aujourd'hui" @@ -1472,7 +1448,7 @@ msgstr "Réessayer" msgid "Type a message…" msgstr "Écrivez un message…" -#: src/pages/PlaylistDetail.tsx:810 +#: src/pages/PlaylistDetail.tsx:755 msgid "Undo" msgstr "Annuler" @@ -1492,12 +1468,12 @@ msgstr "Ne plus suivre la collection" msgid "Unmute" msgstr "Rétablir le son" -#: src/pages/UserPublicProfile.tsx:807 +#: src/components/profile/ProfileHeader.tsx:70 msgid "Upload failed" msgstr "Envoi échoué" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:484 +#: src/components/dump/create/DumpCreateModal.tsx:448 msgid "Uploading {0}%" msgstr "Envoi {0} %" @@ -1505,7 +1481,7 @@ msgstr "Envoi {0} %" msgid "Uploading image…" msgstr "Envoi de l'image…" -#: src/components/dump/create/DumpCreateModal.tsx:797 +#: src/components/dump/create/DumpCreateModal.tsx:760 msgid "Uploading…" msgstr "Envoi…" @@ -1513,13 +1489,13 @@ msgstr "Envoi…" msgid "Upvote" msgstr "Voter" -#: src/pages/UserUpvoted.tsx:186 +#: src/pages/UserUpvoted.tsx:74 msgid "Upvoted" msgstr "Voté" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1057 +#: src/components/profile/ProfileDumpsTab.tsx:51 msgid "Upvoted ({0}{1})" msgstr "Votés ({0}{1})" @@ -1527,7 +1503,7 @@ msgstr "Votés ({0}{1})" msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:401 +#: src/components/dump/create/DumpCreateModal.tsx:397 msgid "URL is required." msgstr "L'URL est obligatoire." @@ -1535,11 +1511,11 @@ msgstr "L'URL est obligatoire." msgid "URL slug" msgstr "Identifiant d’URL" -#: src/pages/UserPublicProfile.tsx:1724 +#: src/components/profile/RoleSwitcher.tsx:22 msgid "User" msgstr "Utilisateur" -#: src/pages/UserPublicProfile.tsx:1442 +#: src/components/profile/ProfileManageTab.tsx:38 msgid "User management" msgstr "Gestion utilisateur" @@ -1552,18 +1528,15 @@ msgstr "Menu utilisateur" msgid "Username" msgstr "Nom d'utilisateur" -#: src/pages/Search.tsx:199 +#: src/pages/Search.tsx:198 msgid "Users" msgstr "Utilisateurs" -#: src/pages/UserPublicProfile.tsx:1127 -#: src/pages/UserPublicProfile.tsx:1216 -#: src/pages/UserPublicProfile.tsx:1537 -#: src/pages/UserPublicProfile.tsx:1669 +#: src/components/profile/ProfileSection.tsx:43 msgid "View all →" msgstr "Tout voir →" -#: src/components/dump/create/DumpCreateModal.tsx:561 +#: src/components/dump/create/DumpCreateModal.tsx:525 msgid "View dump →" msgstr "Voir la reco →" @@ -1571,17 +1544,17 @@ msgstr "Voir la reco →" msgid "Volume" msgstr "Volume" -#: src/components/dump/create/DumpCreateModal.tsx:748 +#: src/components/dump/create/DumpCreateModal.tsx:711 #: src/pages/DumpEdit.tsx:515 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" -#: src/pages/UserPublicProfile.tsx:1011 -#: src/pages/UserPublicProfile.tsx:1923 +#: src/components/profile/DescriptionEditor.tsx:77 +#: src/components/profile/ProfileDescription.tsx:79 msgid "Who am I?" msgstr "Qui suis-je ?" -#: src/components/dump/create/DumpCreateModal.tsx:763 +#: src/components/dump/create/DumpCreateModal.tsx:726 msgid "Who can see it" msgstr "Qui peut la voir" @@ -1589,7 +1562,7 @@ msgstr "Qui peut la voir" msgid "Why & where" msgstr "Pourquoi & où" -#: src/components/dump/create/DumpCreateModal.tsx:747 +#: src/components/dump/create/DumpCreateModal.tsx:710 #: src/pages/DumpEdit.tsx:514 msgid "Why?" msgstr "Pourquoi ?" @@ -1598,7 +1571,7 @@ msgstr "Pourquoi ?" msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/pages/Notifications.tsx:471 +#: src/pages/Notifications.tsx:472 #: src/utils/chat/time.ts:27 msgid "Yesterday" msgstr "Hier" @@ -1607,7 +1580,7 @@ msgstr "Hier" msgid "You can post it anyway." msgstr "Vous pouvez la publier quand même." -#: src/pages/Notifications.tsx:456 +#: src/pages/Notifications.tsx:457 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." @@ -1615,14 +1588,7 @@ msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vo msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" -#: src/components/feed/DumpFeed.tsx:120 -#: src/components/feed/FollowedFeed.tsx:136 -#: src/components/feed/JournalFeed.tsx:91 -#: src/pages/Search.tsx:272 -#: src/pages/UserDumps.tsx:136 -#: src/pages/UserPlaylists.tsx:452 -#: src/pages/UserPlaylists.tsx:487 -#: src/pages/UserUpvoted.tsx:227 +#: src/components/feed/FeedFooter.tsx:39 msgid "You've reached the end." msgstr "Vous avez tout lu, tout vu, tout bu." diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 50cb34ae..8e737a2a 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -39,8 +39,8 @@ import { Avatar } from "../components/shared/Avatar.tsx"; import { RichContentCard } from "../components/dump/RichContentCard.tsx"; import { FilePreview } from "../components/dump/FilePreview.tsx"; import { VoteButton } from "../components/social/VoteButton.tsx"; -import { DumpCard } from "../components/dump/DumpCard.tsx"; -import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { DumpCardList } from "../components/dump/DumpCardList.tsx"; +import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { Markdown } from "../components/editor/Markdown.tsx"; @@ -560,19 +560,7 @@ export function Dump(): React.JSX.Element {

    Related

    -
      - {relatedDumps.map((related) => ( - - ))} -
    +
    )} @@ -581,11 +569,7 @@ export function Dump(): React.JSX.Element {

    In collections

    -
      - {collections.map((collection) => ( - - ))} -
    + )} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 2001dea6..b3a9a8c9 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -5,14 +5,7 @@ * @module */ -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useParams } from "react-router"; import { AppHeader } from "../components/layout/AppHeader.tsx"; @@ -33,7 +26,10 @@ import type { PaginatedData, Wire } from "../../shared/api.ts"; import { expectOk, friendlyFetchError } from "../utils/shared/api.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; -import { useScrollSave } from "../hooks/feed/useScrollSave.ts"; +import { + useScrollRestore, + useScrollSave, +} from "../hooks/feed/useScrollSave.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useWS } from "../hooks/ws/useWS.ts"; @@ -41,22 +37,15 @@ import { useCategories } from "../hooks/category/useCategories.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; -import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx"; +import { + DumpFeed, + type DumpFeedState, + 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"; -type DumpsState = - | { status: "loading" } - | { status: "error"; error: string } - | { - status: "loaded"; - dumps: Dump[]; - hasMore: boolean; - page: number; - loadingMore: boolean; - }; - /** * Passed to {@link Index} by the router, from the `:categorySlug` of the URL. */ @@ -83,15 +72,7 @@ export function Index( ?.deletedDumpId; const { user, token } = useAuth(); - const { - voteCounts, - myVotes, - recentDumps, - deletedDumpIds, - castVote, - removeVote, - connectionEpoch, - } = useWS(); + const { recentDumps, deletedDumpIds, connectionEpoch } = useWS(); const { categories, isLoaded: categoriesLoaded } = useCategories(); const category = categorySlug @@ -130,7 +111,7 @@ export function Index( `feed:index:${categorySlug ?? "all"}`, hydrateDump, ); - const [dumpsState, setDumpsState] = useState(() => + const [dumpsState, setDumpsState] = useState(() => cached ? { status: "loaded", @@ -264,23 +245,12 @@ export function Index( }, [dumpsState, saveState]), ); - const scrollRestored = useRef(false); - useLayoutEffect(() => { - if (cached?.scrollY == null || scrollRestored.current) return; - if (dumpsState.status === "loaded") { - globalThis.scrollTo(0, cached.scrollY); - scrollRestored.current = true; - } - }, [dumpsState.status, cached]); + useScrollRestore(cached?.scrollY, dumpsState.status === "loaded"); - const loading = dumpsState.status === "loading"; - const error = dumpsState.status === "error" ? dumpsState.error : null; const dumps = useMemo( () => dumpsState.status === "loaded" ? dumpsState.dumps : [], [dumpsState], ); - const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; - const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false; // A category's feed takes only the new dumps filed under it. const categoryId = category?.id; @@ -303,17 +273,9 @@ export function Index( ); const mainFeedProps: MainFeedProps = { + state: dumpsState, dumps: combined, - loading, - error, - hasMore, - loadingMore, sentinelRef, - voteCounts, - myVotes, - user, - castVote, - removeVote, }; if (categoryNotFound) return ; @@ -344,14 +306,7 @@ export function Index( )} {tab === "journal" && } {tab === "followed" && user && ( - + )}
    ); diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 1780a332..a9af4f3d 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -17,6 +17,7 @@ import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx"; +import { LoadMoreButton } from "../components/shared/LoadMoreButton.tsx"; import { useWS } from "../hooks/ws/useWS.ts"; import type { CommentLikedData, @@ -508,16 +509,7 @@ export function Notifications(): React.JSX.Element { ))} {state.status === "loaded" && state.hasMore && ( - + )}
    diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 59c4dabe..8d3019c7 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -36,6 +36,7 @@ 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"; @@ -48,6 +49,7 @@ 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"; @@ -98,11 +100,19 @@ export function PlaylistDetail(): React.JSX.Element { const [activeDumpIds, setActiveDumpIds] = useState>(new Set()); // A removed dump shows faded for 2 seconds, while it can be undone, then - // slides out ("dismissing"), as on the upvoted page. - const [fading, setFading] = useState< - Record - >({}); - const cancels = useRef void>>(new Map()); + // 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. @@ -139,7 +149,6 @@ export function PlaylistDetail(): React.JSX.Element { }); useEffect(() => () => { - cancels.current.forEach((c) => c()); if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current); }, []); @@ -176,9 +185,7 @@ export function PlaylistDetail(): React.JSX.Element { setActiveDumpIds(ids); dumpOrderRef.current = order; for (const id of ids) knownDumpIdsRef.current.add(id); - setFading({}); - cancels.current.forEach((c) => c()); - cancels.current.clear(); + cancelAll(); if (dumpReorderTimerRef.current) { clearTimeout(dumpReorderTimerRef.current); dumpReorderTimerRef.current = null; @@ -191,77 +198,13 @@ export function PlaylistDetail(): React.JSX.Element { error: friendlyFetchError(err), }); }); - }, [playlistId, token]); + }, [playlistId, token, cancelAll]); useEffect(() => { fetchPlaylist(); return () => fetchAbortRef.current?.abort(); }, [fetchPlaylist]); - // Fades out dump `id`, then drops it from `state.playlist.dumps`. Its entry - // in `cancels` stops the fade at whichever step it is. - function startFade(id: string) { - if (cancels.current.has(id)) return; - let dead = false; - let kill = () => {}; - - kill = () => { - dead = true; - setFading((f) => { - const n = { ...f }; - delete n[id]; - return n; - }); - cancels.current.delete(id); - }; - cancels.current.set(id, () => kill()); - setFading((f) => ({ ...f, [id]: "cooldown" })); - - const t1 = setTimeout(() => { - if (dead) return; - setFading((f) => ({ ...f, [id]: "dismissing" })); - - const t2 = setTimeout(() => { - if (dead) return; - setState((prev) => { - if (prev.status !== "loaded") return prev; - return { - ...prev, - playlist: { - ...prev.playlist, - dumps: prev.playlist.dumps.filter((d) => d.id !== id), - }, - }; - }); - kill(); - }, 350); - - kill = () => { - dead = true; - clearTimeout(t2); - setFading((f) => { - const n = { ...f }; - delete n[id]; - return n; - }); - cancels.current.delete(id); - }; - cancels.current.set(id, () => kill()); - }, 2000); - - kill = () => { - dead = true; - clearTimeout(t1); - setFading((f) => { - const n = { ...f }; - delete n[id]; - return n; - }); - cancels.current.delete(id); - }; - cancels.current.set(id, () => kill()); - } - // The socket's changes to this playlist: its dumps, its details, its end. useEffect(() => { if (!lastPlaylistEvent || !playlistUUID) return; @@ -283,7 +226,7 @@ export function PlaylistDetail(): React.JSX.Element { n.delete(id); return n; }); - startFade(id); + startFading(id); } } @@ -291,7 +234,7 @@ export function PlaylistDetail(): React.JSX.Element { // "loading", which unsets playlistUUID and runs this effect again. for (const id of newIds) { if (!prev.has(id)) { - cancels.current.get(id)?.(); + cancelFading(id); setActiveDumpIds((s) => new Set([...s, id])); const orderedIds = ev.dumpIds!; fetch(`${API_URL}/api/dumps/${id}`, { @@ -366,7 +309,14 @@ export function PlaylistDetail(): React.JSX.Element { } else if (ev.type === "deleted") { navigate("/"); } - }, [lastPlaylistEvent, playlistUUID, navigate, token]); + }, [ + lastPlaylistEvent, + playlistUUID, + navigate, + token, + startFading, + cancelFading, + ]); // A deleted dump leaves at once, without the removal's fade. useEffect(() => { @@ -497,7 +447,7 @@ export function PlaylistDetail(): React.JSX.Element { ).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); }).catch(() => { - cancels.current.get(dumpId)?.(); + cancelFading(dumpId); knownDumpIdsRef.current.add(dumpId); setActiveDumpIds((prev) => new Set([...prev, dumpId])); // The fade may have taken it out of the list already. @@ -516,12 +466,12 @@ export function PlaylistDetail(): React.JSX.Element { n.delete(dumpId); return n; }); - startFade(dumpId); + startFading(dumpId); }; const handleCancelRemove = (dumpId: string) => { if (!playlistId || state.status !== "loaded") return; - cancels.current.get(dumpId)?.(); + 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. @@ -559,7 +509,7 @@ export function PlaylistDetail(): React.JSX.Element { n.delete(dumpId); return n; }); - startFade(dumpId); + startFading(dumpId); setActionError(t`The dump couldn't be put back. Try again.`); }) .finally(() => { @@ -753,11 +703,6 @@ export function PlaylistDetail(): React.JSX.Element { const phase = fading[dump.id]; // drag index is within the active-only list const activeIndex = isActive ? activeDumps.indexOf(dump) : -1; - const cardCls = phase === "cooldown" - ? "dump-card--fading" - : phase === "dismissing" - ? "dump-card--dismissing" - : undefined; return (
    {canEdit && (isActive @@ -980,22 +925,11 @@ function PlaylistEditForm( name="isPublic" control={form.control} render={({ field }) => ( -
    - - -
    + )} /> diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 21ff827c..a6cc3a45 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -11,12 +11,12 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppHeader } from "../components/layout/AppHeader.tsx"; import { TabBar } from "../components/shared/TabBar.tsx"; -import { DumpCard } from "../components/dump/DumpCard.tsx"; -import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { DumpCardList } from "../components/dump/DumpCardList.tsx"; +import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; +import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; -import { useWS } from "../hooks/ws/useWS.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { useTabParam } from "../hooks/shared/useTabParam.ts"; import { @@ -65,8 +65,7 @@ export function Search(): React.JSX.Element { const q = searchParams.get("q") ?? ""; const [tab, setTab] = useTabParam(SEARCH_TABS, "dumps"); - const { token, user } = useAuth(); - const { voteCounts, myVotes, castVote, removeVote } = useWS(); + const { token } = useAuth(); const [state, setState] = useState({ status: "idle" }); const abortRef = useRef(null); @@ -246,32 +245,15 @@ export function Search(): React.JSX.Element { {state.dumps.items.length === 0 ?

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

    : ( -
      - {state.dumps.items.map((dump) => ( - - ))} -
    + <> + + + )} -
    - {state.dumps.loadingMore && ( -

    - Loading more… -

    - )} - {!state.dumps.hasMore && state.dumps.items.length > 0 && ( -

    - You've reached the end. -

    - )} )} @@ -306,13 +288,7 @@ export function Search(): React.JSX.Element { {t`No playlists match "${q}".`}

    ) - : ( -
      - {state.playlists.map((p) => ( - - ))} -
    - ) + : )}
    diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index 2d8a46cc..68f102c6 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -5,7 +5,6 @@ * @module */ -import { useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Link, useParams } from "react-router"; @@ -16,8 +15,9 @@ import { useWS } from "../hooks/ws/useWS.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts"; import { useUserDumpFeed } from "../hooks/dump/useUserDumpFeed.ts"; -import { DumpCard } from "../components/dump/DumpCard.tsx"; -import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx"; +import { DumpCardList } from "../components/dump/DumpCardList.tsx"; +import { NewDumpButton } from "../components/dump/NewDumpButton.tsx"; +import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; @@ -31,7 +31,7 @@ import { PageError } from "../components/layout/PageError.tsx"; export function UserDumps(): React.JSX.Element { const { username } = useParams(); const { user: me } = useAuth(); - const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS(); + const { lastDumpEvent } = useWS(); useDocumentTitle(username ?? null); @@ -41,8 +41,6 @@ export function UserDumps(): React.JSX.Element { `feed:user-dumps-full:${username ?? ""}`, ); - const [createModalOpen, setCreateModalOpen] = useState(false); - const profileUserId = state.status === "loaded" ? state.profileUser.id : null; const isOwnProfile = me?.id === profileUserId; @@ -88,21 +86,8 @@ export function UserDumps(): React.JSX.Element { username={username!} profileUser={profileUser} title={t`Dumps`} - actions={isOwnProfile && ( - - )} + actions={isOwnProfile && } /> - {createModalOpen && ( - setCreateModalOpen(false)} /> - )} {dumps.length === 0 ? (

    @@ -111,31 +96,12 @@ export function UserDumps(): React.JSX.Element { ) : ( <> -

      - {dumps.map((dump) => ( - - ))} -
    - {hasMore &&
    } - {loadingMore && ( -

    - Loading more… -

    - )} - {!hasMore && ( -

    - You've reached the end. -

    - )} + + )} diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index fd51af9c..7087e391 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -5,13 +5,7 @@ * @module */ -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useState } from "react"; import { Link, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -23,7 +17,6 @@ import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; import { deserializePlaylist, - deserializePublicUser, hydratePlaylist, } from "../utils/shared/deserialize.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; @@ -33,8 +26,20 @@ import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; -import { useScrollSave } from "../hooks/feed/useScrollSave.ts"; -import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { + useScrollRestore, + useScrollSave, +} from "../hooks/feed/useScrollSave.ts"; +import { + fetchFirstPage, + type PagedList, + restoredList, + withFirst, +} from "../utils/feed/pagedList.ts"; +import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts"; +import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; +import { FeedFooter } from "../components/feed/FeedFooter.tsx"; +import { ProfileSection } from "../components/profile/ProfileSection.tsx"; import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx"; import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; import { ConfirmModal } from "../components/shared/ConfirmModal.tsx"; @@ -42,28 +47,16 @@ import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; -/** One of the page's two lists, as far as it is loaded. */ -interface PlaylistFeed { - items: Playlist[]; - hasMore: boolean; - page: number; - loadingMore: boolean; -} - type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; profileUser: PublicUser; - created: PlaylistFeed; - followed: PlaylistFeed; + created: PagedList; + followed: PagedList; }; -function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed { - return { items, hasMore, page: 1, loadingMore: false }; -} - /** * Renders `/users/:username/playlists`, for anyone. It lists the playlists the * user made (the private ones only to them) and the public ones they follow, @@ -139,79 +132,29 @@ export function UserPlaylists(): React.JSX.Element { useEffect(() => { if (!username) return; const controller = new AbortController(); - - const authHeaders: HeadersInit = token - ? { Authorization: `Bearer ${token}` } - : {}; + const { signal } = controller; // Back on the page, the lists come from the cache and only the user is // fetched again. - if (cachedCreated && cachedFollowed) { - fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) - .then((r) => r.json()) - .then((body) => { - if (!body.success) throw new Error("User not found"); - setState({ - status: "loaded", - profileUser: deserializePublicUser(body.data), - created: { - items: cachedCreated.items, - hasMore: cachedCreated.hasMore, - page: cachedCreated.page, - loadingMore: false, - }, - followed: { - items: cachedFollowed.items, - hasMore: cachedFollowed.hasMore, - page: cachedFollowed.page, - loadingMore: false, - }, - }); - }) - .catch((err) => { - if (err.name === "AbortError") return; - setState({ status: "error", error: friendlyFetchError(err) }); - }); - return () => controller.abort(); - } - + const cached = cachedCreated && cachedFollowed; Promise.all([ - fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }), - fetch( - `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { headers: authHeaders, signal: controller.signal }, + fetchPublicUser(username, signal), + cached ? restoredList(cachedCreated) : fetchFirstPage( + `${API_URL}/api/users/${username}/playlists`, + deserializePlaylist, + { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal }, ), - fetch( - `${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { signal: controller.signal }, + cached ? restoredList(cachedFollowed) : fetchFirstPage( + `${API_URL}/api/users/${username}/followed-playlists`, + deserializePlaylist, + { signal }, ), ]) - .then(([userRes, createdRes, followedRes]) => - Promise.all([userRes.json(), createdRes.json(), followedRes.json()]) + .then(([profileUser, created, followed]) => + setState({ status: "loaded", profileUser, created, followed }) ) - .then(([userBody, createdBody, followedBody]) => { - if (!userBody.success) throw new Error("User not found"); - const createdData = createdBody.success - ? createdBody.data as PaginatedData> - : { items: [], hasMore: false }; - const followedData = followedBody.success - ? followedBody.data as PaginatedData> - : { items: [], hasMore: false }; - setState({ - status: "loaded", - profileUser: deserializePublicUser(userBody.data), - created: initialFeed( - createdData.items.map(deserializePlaylist), - createdData.hasMore, - ), - followed: initialFeed( - followedData.items.map(deserializePlaylist), - followedData.hasMore, - ), - }); - }) .catch((err) => { - if (err.name === "AbortError") return; + if (signal.aborted) return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); @@ -326,14 +269,7 @@ export function UserPlaylists(): React.JSX.Element { }, [state, saveCreated, saveFollowed]), ); - const scrollRestored = useRef(false); - useLayoutEffect(() => { - if (cachedCreated?.scrollY == null || scrollRestored.current) return; - if (state.status === "loaded") { - globalThis.scrollTo(0, cachedCreated.scrollY); - scrollRestored.current = true; - } - }, [state.status, cachedCreated]); + useScrollRestore(cachedCreated?.scrollY, state.status === "loaded"); const [deleteError, setDeleteError] = useState(null); @@ -348,17 +284,7 @@ export function UserPlaylists(): React.JSX.Element { setDeleteError(friendlyFetchError(err)); return; } - setState((s) => - s.status === "loaded" - ? { - ...s, - created: { - ...s.created, - items: s.created.items.filter((p) => p.id !== playlistId), - }, - } - : s - ); + setCreated((items) => items.filter((p) => p.id !== playlistId)); }; if (state.status === "loading") { @@ -395,15 +321,7 @@ export function UserPlaylists(): React.JSX.Element { actions={isOwnProfile && ( - setState((s) => { - if (s.status !== "loaded") return s; - if (s.created.items.some((pl) => pl.id === p.id)) return s; - return { - ...s, - created: { ...s.created, items: [p, ...s.created.items] }, - }; - })} + onCreated={(p) => setCreated((items) => withFirst(items, p))} /> )} /> @@ -412,15 +330,14 @@ export function UserPlaylists(): React.JSX.Element { )} -
    -
    -

    - - Created ({created.items.length} - {created.hasMore ? "+" : ""}) - -

    -
    + + Created ({created.items.length} + {created.hasMore ? "+" : ""}) + + } + > {created.items.length === 0 ? (

    @@ -428,41 +345,30 @@ export function UserPlaylists(): React.JSX.Element {

    ) : ( -
      - {created.items.map((p) => ( - setConfirmDeleteId(p.id) - : undefined} - /> - ))} -
    + <> + setConfirmDeleteId(p.id) + : undefined} + /> + + )} - {created.hasMore &&
    } - {created.loadingMore && ( -

    - Loading more… -

    - )} - {!created.hasMore && created.items.length > 0 && ( -

    - You've reached the end. -

    - )} -
    + -
    -
    -

    - - Followed ({followed.items.length} - {followed.hasMore ? "+" : ""}) - -

    -
    + + Followed ({followed.items.length} + {followed.hasMore ? "+" : ""}) + + } + > {followed.items.length === 0 ? (

    @@ -470,24 +376,16 @@ export function UserPlaylists(): React.JSX.Element {

    ) : ( -
      - {followed.items.map((p) => ( - - ))} -
    + <> + + + )} - {followed.hasMore &&
    } - {followed.loadingMore && ( -

    - Loading more… -

    - )} - {!followed.hasMore && followed.items.length > 0 && ( -

    - You've reached the end. -

    - )} -
    + {confirmDeleteId && ( (null); - const [pending, setPending] = useState(false); - const [copied, setCopied] = useState(false); - const [error, setError] = useState(null); - const ref = useHeightTransition( - (inviteUrl ?? "") + (error ?? ""), - ); - - async function toggle() { - setError(null); - setCopied(false); - if (inviteUrl) { - setInviteUrl(null); - return; - } - setPending(true); - try { - const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" }); - const body = await res.json(); - if (body.success) { - setInviteUrl( - `${globalThis.location.origin}/register?token=${body.data.token}`, - ); - } else { - setError(t`Failed to generate invite`); - } - } catch { - setError(t`Failed to generate invite`); - } finally { - setPending(false); - } - } - - async function copy() { - if (!inviteUrl) return; - try { - await navigator.clipboard.writeText(inviteUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // The clipboard is denied outside a secure context. The link can still - // be selected by hand. - } - } - - return ( -
    -
    - - {children} -
    - {inviteUrl && ( -
    - e.currentTarget.select()} - /> - - - Single use, valid for 7 days. - -
    - )} - {error && ( -
    - -
    - )} -
    - ); -} - -/** - * A list of the profile, of which `page` pages are loaded. The profile holds - * the first page only, since each has its own page for the rest, but the - * followed users. - */ -interface PaginatedList { - items: T[]; - hasMore: boolean; - page: number; - loadingMore: boolean; -} - -function initialList(items: T[], hasMore: boolean): PaginatedList { - return { items, hasMore, page: 1, loadingMore: false }; -} - -/** - * The profile's user and its first page of dumps, upvoted dumps and playlists, - * fetched together. - */ -type ProfileState = - | { status: "loading" } - | { status: "error"; error: string } - | { - status: "loaded"; - user: PublicUser; - dumps: PaginatedList; - votes: PaginatedList; - playlists: PaginatedList; - }; - -/** Null until the "followed" tab is first opened, which fetches it. */ -type FollowedState = - | null - | { status: "loading" } - | { status: "error"; error: string } - | { - status: "loaded"; - users: PaginatedList; - playlists: PaginatedList; - }; - -/** Null until the "invitees" tab is first opened, which fetches it. */ -type InviteTreeState = - | null - | { status: "loading" } - | { status: "error"; error: string } - | { status: "loaded"; entries: Invitee[] }; - -type InviteTreeNode = Invitee & { children: InviteTreeNode[] }; /** * The profile's tabs, the `:profileTab` of `/users/:username/~/:profileTab`. * "dumps" is the default, at `/users/:username`. @@ -244,62 +53,22 @@ const PROFILE_TABS = [ ] as const; type ProfileTab = (typeof PROFILE_TABS)[number]; -/** - * Returns the invitees of `parentId` among `entries`, each with theirs. The - * server answers the whole tree as a flat list. - */ -function buildInviteTree( - entries: Invitee[], - parentId: string, -): InviteTreeNode[] { - return entries - .filter((e) => e.invitedById === parentId) - .map((e) => ({ ...e, children: buildInviteTree(entries, e.id) })); -} - /** * Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone. - * Its tabs show the user's dumps and upvotes, playlists, follows and invitees, - * and the lists follow the socket's events. On their own profile, users edit - * their avatar, description and email, create invites, and get a "settings" - * tab for their password and this browser's preferences. The "manage" tab - * shows another user's role to those who hold `user:manage`, and one's own - * categories to those who hold `category:manage`. + * `useProfile` loads the user with their dumps, upvotes and playlists; the + * "followed" and "invitees" tabs load theirs when first opened, and keep it + * while other tabs show. Each tab is a component of `components/profile/`. On + * their own profile, users edit their avatar, description and email, create + * invites, and get a "settings" tab. The "manage" tab shows another user's + * role to those who hold `user:manage`, and one's own categories to those who + * hold `category:manage`. */ export function UserPublicProfile(): React.JSX.Element { const { username } = useParams(); const navigate = useNavigate(); - const { user: me, authFetch, updateSession, logout, token } = useAuth(); - const { style, colorScheme, setStyle, setColorScheme } = useTheme(); - const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); - const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); - const { - voteCounts, - myVotes, - lastVoteEvent, - lastDumpEvent, - lastPlaylistEvent, - lastUserEvent, - castVote, - removeVote, - } = useWS(); - - const { cached: cachedDumps, saveState: saveDumps } = useFeedCache( - `feed:profile-dumps:${username ?? ""}`, - hydrateDump, - ); - const { cached: cachedVotes, saveState: saveVotes } = useFeedCache( - `feed:profile-votes:${username ?? ""}`, - hydrateDump, - ); - const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< - Playlist - >( - `feed:profile-playlists:${username ?? ""}`, - hydratePlaylist, - ); - - const [state, setState] = useState({ status: "loading" }); + const { user: me, logout } = useAuth(); + const { state, isOwnProfile, setUser, setPlaylists, upvotedCardClassName } = + useProfile(username); useDocumentTitle( state.status === "loaded" @@ -309,9 +78,6 @@ export function UserPublicProfile(): React.JSX.Element { : undefined, ); - const profileUserId = state.status === "loaded" ? state.user.id : null; - const isOwnProfile = me?.id === profileUserId; - const canManageUsers = can(me, "user:manage"); const canManageCategories = can(me, "category:manage"); // "manage" holds tools for another user on their profile, and site-wide @@ -325,432 +91,42 @@ export function UserPublicProfile(): React.JSX.Element { // A tab not available here, as while the profile loads, shows "dumps". const [tab, setTab] = useTabParam(availableTabs, "dumps"); - const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { - setState((s) => - s.status !== "loaded" - ? s - : { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } } - ); - }, []); - const dumpItems = state.status === "loaded" ? state.dumps.items : []; - usePositionAwareSync( - dumpItems, - setDumps, - lastDumpEvent, - (d) => !d.isPrivate && d.userId === profileUserId, + const loaded = state.status === "loaded"; + const followed = useFetchState( + username, + (signal) => + Promise.all([ + fetchFirstPage( + `${API_URL}/api/users/${username}/followed-users`, + deserializePublicUser, + { signal }, + ), + fetchFirstPage( + `${API_URL}/api/users/${username}/followed-playlists`, + deserializePlaylist, + { signal }, + ), + ]).then(([users, playlists]) => ({ users, playlists })), + loaded && tab === "followed", ); - useDumpListSync(setDumps, { - isOwner: isOwnProfile, - }); - - // A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade - // out. If the dump then turns private and public again, it must not return. - const withdrawnVoteIdsRef = useRef>(new Set()); - - const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => { - setState((s) => - s.status !== "loaded" - ? s - : { ...s, votes: { ...s.votes, items: fn(s.votes.items) } } - ); - }, []); - const voteItems = state.status === "loaded" ? state.votes.items : []; - usePositionAwareSync( - voteItems, - setVotes, - lastDumpEvent, - (d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id), + const invitees = useFetchState( + username, + (signal) => + fetch(`${API_URL}/api/users/${username}/invitees`, { signal }) + .then((res) => dataOr[]>(res, [])) + .then((entries) => entries.map(deserializeInvitee)), + loaded && tab === "invitees", ); - useDumpListSync(setVotes); - const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { - setState((s) => - s.status !== "loaded" - ? s - : { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } } - ); - }, []); - const playlistItems = state.status === "loaded" ? state.playlists.items : []; - const lastPlaylistItem = lastPlaylistEvent?.type === "updated" - ? (lastPlaylistEvent.playlist ?? null) - : null; - usePositionAwareSync( - playlistItems, - setPlaylists, - lastPlaylistItem, - (p) => p.isPublic && p.userId === profileUserId, - ); - usePlaylistListSync(setPlaylists, { - isOwner: isOwnProfile, - ownerId: profileUserId ?? undefined, - }); - - useEffect(() => { - if (!lastUserEvent) return; - const { user } = lastUserEvent; - // Mirrors a socket event into state, which can only happen in an effect. - // eslint-disable-next-line react-hooks/set-state-in-effect - setState((s) => { - if (s.status !== "loaded" || s.user.id !== user.id) return s; - return { ...s, user }; - }); - }, [lastUserEvent]); - - const [uploading, setUploading] = useState(false); - const [avatarError, setAvatarError] = useState(null); - const fileInputRef = useRef(null); - - const [descEditing, setDescEditing] = useState(false); - const [emailEditing, setEmailEditing] = useState(false); - const infoRef = useHeightTransition(emailEditing); - const descRef = useHeightTransition(descEditing); - const prevMyVotesRef = useRef | null>(null); - - const [changePasswordOpen, setChangePasswordOpen] = useState(false); - const [followedState, setFollowedState] = useState(null); - const [inviteTreeState, setInviteTreeState] = useState(null); - - const inviteTreeNodes = useMemo(() => { - if (!profileUserId || inviteTreeState?.status !== "loaded") return []; - return buildInviteTree(inviteTreeState.entries, profileUserId); - }, [inviteTreeState, profileUserId]); - - const [prevUsername, setPrevUsername] = useState(username); - if (prevUsername !== username) { - setPrevUsername(username); - setState({ status: "loading" }); - setFollowedState(null); - setInviteTreeState(null); - } - - // A ref can't be written during render. This effect runs before the one - // below that diffs the votes, so it still resets in time. - useEffect(() => { - prevMyVotesRef.current = null; - }, [username]); - - useEffect(() => { - if (!username) return; - const controller = new AbortController(); - - const allCached = cachedDumps && cachedVotes && cachedPlaylists; - - if (allCached) { - // The lists come from this tab's session cache, as on Back. The user is - // always fetched afresh. - fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) - .then((r) => r.json()) - .then((body) => { - if (!body.success) throw new Error("User not found"); - const profileUser = deserializePublicUser(body.data); - setState({ - status: "loaded", - user: profileUser, - dumps: { - items: cachedDumps.items, - hasMore: cachedDumps.hasMore, - page: cachedDumps.page, - loadingMore: false, - }, - votes: { - items: cachedVotes.items, - hasMore: cachedVotes.hasMore, - page: cachedVotes.page, - loadingMore: false, - }, - playlists: { - items: cachedPlaylists.items, - hasMore: cachedPlaylists.hasMore, - page: cachedPlaylists.page, - loadingMore: false, - }, - }); - }) - .catch((err) => { - if (err.name === "AbortError") return; - setState({ status: "error", error: friendlyFetchError(err) }); - }); - return () => controller.abort(); - } - - (async () => { - try { - const authHeaders: HeadersInit = token - ? { Authorization: `Bearer ${token}` } - : {}; - const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([ - fetch(`${API_URL}/api/users/${username}`, { - signal: controller.signal, - }), - fetch( - `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { headers: authHeaders, signal: controller.signal }, - ), - fetch( - `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { headers: authHeaders, signal: controller.signal }, - ), - fetch( - `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { headers: authHeaders, signal: controller.signal }, - ), - ]); - - if (!userRes.ok) { - throw new Error( - userRes.status === 404 - ? "User not found" - : `HTTP ${userRes.status}`, - ); - } - - const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise - .all([ - userRes.json(), - dumpsRes.json(), - votesRes.json(), - playlistsRes.json(), - ]); - - const votesData: PaginatedData> = votesBody.success - ? votesBody.data - : { items: [], hasMore: false }; - const playlistsData: PaginatedData> = - playlistsBody.success - ? playlistsBody.data - : { items: [], hasMore: false }; - const dumpsData: PaginatedData> = dumpsBody.success - ? dumpsBody.data - : { items: [], hasMore: false }; - - const profileUser = deserializePublicUser(userBody.data); - const voteItems = votesData.items.map(deserializeDump); - setState({ - status: "loaded", - user: profileUser, - dumps: initialList( - dumpsData.items.map(deserializeDump), - dumpsData.hasMore, - ), - votes: initialList(voteItems, votesData.hasMore), - playlists: initialList( - playlistsData.items.map(deserializePlaylist), - playlistsData.hasMore, - ), - }); - } catch (err) { - if ((err as Error).name === "AbortError") return; - setState({ - status: "error", - error: friendlyFetchError(err), - }); - } - })(); - return () => controller.abort(); - }, [username, cachedDumps, cachedVotes, cachedPlaylists, token]); - - // On one's own profile, a vote on one's own dump shows in the upvoted list - // at once, from the optimistic `myVotes`, before the socket's vote event. - useEffect(() => { - if (!profileUserId || me?.id !== profileUserId) return; - if (prevMyVotesRef.current === null) { - prevMyVotesRef.current = new Set(myVotes); - return; - } - const prev = prevMyVotesRef.current; - setState((s) => { - if (s.status !== "loaded") return s; - const voteIds = new Set(s.votes.items.map((d) => d.id)); - const toAdd = s.dumps.items.filter((d) => - myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id) - ); - if (toAdd.length === 0) return s; - return { - ...s, - votes: { ...s.votes, items: [...toAdd, ...s.votes.items] }, - }; - }); - prevMyVotesRef.current = new Set(myVotes); - }, [myVotes, me, profileUserId]); - - // Votes by the profile's user, from any browser, update the upvoted list. - useEffect(() => { - if (!lastVoteEvent || !profileUserId) return; - const { dumpId, voterId, action } = lastVoteEvent; - if (voterId !== profileUserId) return; - - if (action === "remove") { - // The dump stays in the list: UpvotedDumpList fades it out, then hides - // it. - withdrawnVoteIdsRef.current.add(dumpId); - } else { - withdrawnVoteIdsRef.current.delete(dumpId); - // Signed in, or a private dump the viewer owns can't be fetched. - fetch(`${API_URL}/api/dumps/${dumpId}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }) - .then((r) => r.json()) - .then((body) => { - if (!body.success) return; - const dump = deserializeDump(body.data); - setState((s) => { - if (s.status !== "loaded") return s; - const idx = s.votes.items.findIndex((d) => d.id === dumpId); - if (idx !== -1) { - // A withdrawn vote cast again, before its dump was reloaded. - const items = [...s.votes.items]; - items[idx] = dump; - return { ...s, votes: { ...s.votes, items } }; - } - return { - ...s, - votes: { ...s.votes, items: [dump, ...s.votes.items] }, - }; - }); - }) - .catch(() => {}); - } - }, [lastVoteEvent, profileUserId, token]); - - // Saves the lists and the scroll position, so that Back restores both. - useEffect(() => { - if (state.status !== "loaded") return; - let timer: ReturnType; - const onScroll = () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (state.status !== "loaded") return; - const y = globalThis.scrollY; - saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y); - saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y); - savePlaylists( - state.playlists.items, - state.playlists.page, - state.playlists.hasMore, - y, - ); - }, 100); - }; - globalThis.addEventListener("scroll", onScroll, { passive: true }); - return () => { - globalThis.removeEventListener("scroll", onScroll); - clearTimeout(timer); - }; - }, [state, saveDumps, saveVotes, savePlaylists]); - - // The playlists also change from the socket without a scroll, and the cache - // must not restore a stale list. - const playlistFeed = state.status === "loaded" ? state.playlists : null; - useEffect(() => { - if (!playlistFeed) return; - savePlaylists( - playlistFeed.items, - playlistFeed.page, - playlistFeed.hasMore, - globalThis.scrollY, - ); - }, [playlistFeed, savePlaylists]); - - // The two lazy tabs' fetches aren't aborted when the user switches tabs: - // they finish in the background. Only another profile or leaving the page - // aborts them, since their state is reset then anyway. - const followedFetchRef = useRef(null); - const inviteesFetchRef = useRef(null); - useEffect(() => () => { - followedFetchRef.current?.abort(); - inviteesFetchRef.current?.abort(); - }, [username]); - - useEffect(() => { - if ( - tab !== "followed" || followedState !== null || state.status !== "loaded" - ) { - return; - } - // Deferred, since the lint forbids a setState in an effect's body. - queueMicrotask(() => setFollowedState({ status: "loading" })); - const controller = new AbortController(); - followedFetchRef.current = controller; - Promise.all([ - fetch( - `${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { signal: controller.signal }, - ), - fetch( - `${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, - { signal: controller.signal }, - ), - ]) - .then(([ur, pr]) => Promise.all([ur.json(), pr.json()])) - .then(([ub, pb]) => { - const usersData = ub.success - ? ub.data as PaginatedData> - : { items: [], hasMore: false }; - const playlistsData = pb.success - ? pb.data as PaginatedData> - : { items: [], hasMore: false }; - setFollowedState({ - status: "loaded", - users: initialList( - usersData.items.map(deserializePublicUser), - usersData.hasMore, - ), - playlists: initialList( - playlistsData.items.map(deserializePlaylist), - playlistsData.hasMore, - ), - }); - }) - .catch((err) => { - if (err.name === "AbortError") return; - setFollowedState({ status: "error", error: friendlyFetchError(err) }); - }); - // followedState only guards the first fetch; as a dependency, its change - // to "loading" would run this again. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab, state.status, username]); - - useEffect(() => { - if ( - tab !== "invitees" || inviteTreeState !== null || - state.status !== "loaded" - ) { - return; - } - queueMicrotask(() => setInviteTreeState({ status: "loading" })); - const controller = new AbortController(); - inviteesFetchRef.current = controller; - fetch( - `${API_URL}/api/users/${username}/invitees`, - { signal: controller.signal }, - ) - .then((r) => r.json()) - .then((body) => { - const entries: Invitee[] = body.success - ? (body.data as Wire[]).map(deserializeInvitee) - : []; - setInviteTreeState({ status: "loaded", entries }); - }) - .catch((err) => { - if (err.name === "AbortError") return; - setInviteTreeState({ status: "error", error: friendlyFetchError(err) }); - }); - // inviteTreeState only guards the first fetch, as followedState above. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab, state.status, username]); - - // The followed users have no page of their own, so the list grows here. const loadMoreFollowedUsers = () => { if ( - followedState?.status !== "loaded" || followedState.users.loadingMore + followed.state.status !== "loaded" || + followed.state.data.users.loadingMore ) return; - const nextPage = followedState.users.page + 1; + const nextPage = followed.state.data.users.page + 1; const setUsers = ( - fn: (u: PaginatedList) => PaginatedList, - ) => - setFollowedState((s) => - s?.status === "loaded" ? { ...s, users: fn(s.users) } : s - ); + 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}`, @@ -767,68 +143,6 @@ export function UserPublicProfile(): React.JSX.Element { .catch(() => setUsers((u) => ({ ...u, loadingMore: false }))); }; - // Before paint, so the restored page doesn't flash at the top. - const scrollRestored = useRef(false); - useLayoutEffect(() => { - if (cachedDumps?.scrollY == null || scrollRestored.current) return; - if (state.status === "loaded") { - globalThis.scrollTo(0, cachedDumps.scrollY); - scrollRestored.current = true; - } - }, [state.status, cachedDumps]); - - const handleAvatarUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file || state.status !== "loaded") return; - - setAvatarError(null); - setUploading(true); - - try { - const formData = new FormData(); - formData.append("file", file); - - const res = await authFetch(`${API_URL}/api/avatars/me`, { - method: "POST", - body: formData, - }); - const body = parseAPIResponse>(await res.json()); - - if (!body.success) { - setAvatarError(body.error.message); - return; - } - - const user = deserializeSelfUser(body.data); - updateSession({ user }); - - setState((prev) => prev.status === "loaded" ? { ...prev, user } : prev); - } catch { - setAvatarError(t`Upload failed`); - } finally { - setUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ""; - } - }; - - const handleEmailSaved = (email: string) => { - if (me) updateSession({ user: { ...me, email } }); - setEmailEditing(false); - }; - - const handleDescSaved = (description: string | undefined) => { - setState((s) => - s.status === "loaded" ? { ...s, user: { ...s.user, description } } : s - ); - setDescEditing(false); - }; - - const handleRoleSaved = (role: Role) => { - setState((s) => - s.status === "loaded" ? { ...s, user: { ...s.user, role } } : s - ); - }; - if (state.status === "loading") { return ( @@ -863,163 +177,20 @@ export function UserPublicProfile(): React.JSX.Element { ); } - const { user: profileUser, dumps, votes, playlists } = state; + const { user, dumps, votes, playlists } = state; return ( -
    -
    - - {isOwnProfile && ( - - )} -
    -
    -
    -
    -

    {profileUser.username}

    - -
    - {profileUser.invitedByUsername - ? ( -

    - invited by{" "} - - @{profileUser.invitedByUsername} - -

    - ) - : ( -

    - O.G. -

    - )} - {isOwnProfile && ( - emailEditing - ? ( - setEmailEditing(false)} - onSaved={handleEmailSaved} - /> - ) - : ( -

    setEmailEditing(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setEmailEditing(true); - } - }} - tabIndex={0} - role="button" - title={t`Edit email`} - > - {me?.email ?? t`Add email…`} - - ✎ - -

    - ) - )} - {avatarError && ( - - )} - {!isOwnProfile && ( - - )} - {isOwnProfile && ( - - - - )} -
    -
    -
    - - {(profileUser.description || isOwnProfile) && ( -
    - {descEditing - ? ( - setDescEditing(false)} - onSaved={handleDescSaved} - /> - ) - : ( -
    setDescEditing(true) - : undefined} - // Focusable rather than a button, so the Markdown inside - // keeps its own structure and links. - tabIndex={isOwnProfile ? 0 : undefined} - title={isOwnProfile ? t`Edit description` : undefined} - onKeyDown={isOwnProfile - ? (e) => { - if ( - (e.key === "Enter" || e.key === " ") && - e.target === e.currentTarget - ) { - e.preventDefault(); - setDescEditing(true); - } - } - : undefined} - > - {profileUser.description - ? ( - - {profileUser.description} - - ) - : ( -
    - Who am I? -
    - )} - {isOwnProfile && ( - - ✎ - - )} -
    - )} -
    - )} + setUser(() => u)} + /> + setUser((u) => ({ ...u, description }))} + /> {tab === "dumps" && ( -
    - - -
    + )} - {tab === "playlists" && ( -
    -
    -

    - - Playlists ({playlists.items.length} - {playlists.hasMore ? "+" : ""}) - -

    - {isOwnProfile && ( - - setState((s) => { - if (s.status !== "loaded") return s; - if ( - s.playlists.items.some((pl) => - pl.id === p.id - ) - ) { - return s; - } - return { - ...s, - playlists: { - ...s.playlists, - items: [p, ...s.playlists.items], - }, - }; - })} - /> - )} -
    - {playlists.items.length === 0 - ? ( -

    - No playlists yet. -

    - ) - : ( -
      - {playlists.items.map((p) => ( - - ))} -
    - )} - {playlists.items.length > 0 && ( - - View all → - - )} -
    + )} - {tab === "followed" && ( -
    -
    -

    - Following -

    - {followedState === null || followedState.status === "loading" - ? ( -

    - Loading… -

    - ) - : followedState.status === "error" - ? ( - - ) - : followedState.users.items.length === 0 - ? ( -

    - Not following anyone yet. -

    - ) - : ( - <> -
      - {followedState.users.items.map((u) => ( - - ))} -
    - {followedState.users.hasMore && ( - - )} - - )} -
    - -
    -

    - Followed playlists -

    - {followedState === null || followedState.status === "loading" - ? ( -

    - Loading… -

    - ) - : followedState.status === "error" - ? ( - - ) - : followedState.playlists.items.length === 0 - ? ( -

    - No followed playlists yet. -

    - ) - : ( - <> -
      - {followedState.playlists.items.map((p) => ( - - ))} -
    - {followedState.playlists.hasMore && ( - - View all → - - )} - - )} -
    -
    + )} - {tab === "invitees" && ( -
    -

    - Invitees -

    - {inviteTreeState === null || inviteTreeState.status === "loading" - ? ( -

    - Loading… -

    - ) - : inviteTreeState.status === "error" - ? ( - - ) - : inviteTreeState.entries.length === 0 - ? ( -

    - No invitees yet. -

    - ) - : } -
    + )} - - {changePasswordOpen && ( - setChangePasswordOpen(false)} /> - )} - - {tab === "settings" && isOwnProfile && ( - <> -
    -

    - Account -

    -
    -
    - - Password - - -
    -
    -
    -
    -

    - Appearance -

    -
    -
    - - Style - -
    - - - - - - -
    -
    -
    - - Color scheme - -
    - - - -
    -
    -
    -
    -
    -

    - Feeds -

    -
    -
    - - Default tab - -
    - - - - -
    -
    -
    -
    -
    -

    - Notifications -

    -
    -
    - - Sounds - -
    - - -
    -
    -
    -
    - - )} - - {tab === "manage" && !isOwnProfile && canManageUsers && ( -
    -

    - User management -

    -
    - -
    -
    - )} - - {tab === "manage" && isOwnProfile && canManageCategories && ( -
    -

    - Categories -

    - -
    + {tab === "settings" && isOwnProfile && } + {tab === "manage" && ( + setUser((u) => ({ ...u, role }))} + /> )}
    ); } - -/** - * Renders the first page of the user's dumps, with a link to all of them. The - * owner also gets a "New dump" button. - */ -function DumpList( - { - title, - dumps, - voteCounts, - myVotes, - canVote, - castVote, - removeVote, - isOwnProfile, - viewAllHref, - }: { - title: string; - dumps: Dump[]; - voteCounts: Record; - myVotes: Set; - canVote: boolean; - castVote: (id: string, shownCount?: number) => void; - removeVote: (id: string, shownCount?: number) => void; - isOwnProfile?: boolean; - viewAllHref: string; - }, -) { - const [createModalOpen, setCreateModalOpen] = useState(false); - return ( -
    -
    -

    {title}

    - {isOwnProfile && ( - - )} -
    - {createModalOpen && ( - setCreateModalOpen(false)} /> - )} - {dumps.length === 0 - ? ( -

    - Nothing here yet. -

    - ) - : ( -
      - {dumps.map((dump) => ( - - ))} -
    - )} - {dumps.length > 0 && ( - - View all → - - )} -
    - ); -} - -/** - * Renders the first page of the dumps the user upvoted. A dump whose vote is - * withdrawn fades out, and comes back if the vote is cast again meanwhile. - */ -function UpvotedDumpList( - { - title, - dumps, - profileUserId, - isOwnProfile, - voteCounts, - myVotes, - canVote, - castVote, - removeVote, - viewAllHref, - }: { - title: string; - dumps: Dump[]; - profileUserId: string | null; - isOwnProfile: boolean; - voteCounts: Record; - myVotes: Set; - canVote: boolean; - castVote: (id: string, shownCount?: number) => void; - removeVote: (id: string, shownCount?: number) => void; - viewAllHref: string; - }, -) { - const { myVotes: wsMyVotes, lastVoteEvent } = useWS(); - const { fading, startFading, cancelFading } = useFading(); - - // Held here rather than derived, so that a dump leaves `votedIds` and starts - // fading in the same render. In between, the card would unmount and its - // transition break. - const [votedIds, setVotedIds] = useState(() => - new Set(dumps.map((d) => d.id)) - ); - const prevMyVotesRef = useRef | null>(null); - - // On one's own profile, votes follow `myVotes`, which changes at once on a - // vote here. This must be an effect, since startFading and cancelFading set - // state too. - useEffect(() => { - if (!profileUserId || !isOwnProfile) return; - if (prevMyVotesRef.current === null) { - // Only a baseline. myVotes may still be empty, from before the socket's - // welcome, and would hide every dump. - prevMyVotesRef.current = new Set(wsMyVotes); - return; - } - const prev = prevMyVotesRef.current; - setVotedIds((ids) => { - const next = new Set(ids); - for (const id of prev) if (!wsMyVotes.has(id)) next.delete(id); - for (const id of wsMyVotes) next.add(id); - return next; - }); - for (const id of prev) if (!wsMyVotes.has(id)) startFading(id); - for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id); - prevMyVotesRef.current = new Set(wsMyVotes); - }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]); - - // On another's profile, votes follow the socket's vote events. - useEffect(() => { - if (!lastVoteEvent || !profileUserId || isOwnProfile) return; - const { dumpId, voterId, action } = lastVoteEvent; - if (voterId !== profileUserId) return; - if (action === "remove") { - // Both updates must land in one render, as above. - // eslint-disable-next-line react-hooks/set-state-in-effect - setVotedIds((prev) => { - const n = new Set(prev); - n.delete(dumpId); - return n; - }); - startFading(dumpId); - } else { - setVotedIds((prev) => new Set([...prev, dumpId])); - cancelFading(dumpId); - } - }, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]); - - const visibleDumps = dumps.filter((d) => - votedIds.has(d.id) || d.id in fading - ); - - return ( -
    -
    -

    {title}

    -
    - {visibleDumps.length === 0 - ? ( -

    - Nothing here yet. -

    - ) - : ( -
      - {visibleDumps.map((dump) => { - const phase = fading[dump.id]; - const extraCls = phase === "cooldown" - ? "dump-card--fading" - : phase === "dismissing" - ? "dump-card--dismissing" - : undefined; - return ( - - ); - })} -
    - )} - {visibleDumps.length > 0 && ( - - View all → - - )} -
    - ); -} - -/** Renders the invite tree, each invitee nested under who invited them. */ -function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) { - return ( -
      - {nodes.map((node) => ( -
    • - - - @{node.username} - - {node.children.length > 0 && } -
    • - ))} -
    - ); -} - -/** Renders a followed user, with a button to follow or unfollow them. */ -function FollowedUserCard({ user }: { user: PublicUser }) { - return ( -
  • - - - @{user.username} - - -
  • - ); -} - -const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [ - { value: "user", label: User }, - { value: "moderator", label: Moderator }, - { value: "admin", label: Admin }, -]; - -/** - * Renders the role badge next to the username, for moderators and admins - * only. - */ -function RoleChip({ role }: { role: Role }) { - if (role === "user") return null; - const label = ROLE_OPTIONS.find((o) => o.value === role)?.label; - return ( - - {label} - - ); -} - -interface RoleSwitcherProps { - userId: string; - currentRole: Role; - onSaved: (role: Role) => void; -} - -/** - * Renders the control that sets another user's role, which its caller shows - * only to those who hold `user:manage`. The server checks it again. - */ -function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) { - const { authFetch } = useAuth(); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - async function changeRole(role: Role) { - if (role === currentRole || saving) return; - setSaving(true); - setError(null); - try { - const res = await authFetch(`${API_URL}/api/users/${userId}/role`, { - method: "PATCH", - body: JSON.stringify({ role }), - }); - const body = parseAPIResponse>(await res.json()); - if (!body.success) { - setError(body.error.message); - return; - } - onSaved(deserializePublicUser(body.data).role); - } catch (err) { - setError(friendlyFetchError(err)); - } finally { - setSaving(false); - } - } - - return ( - <> -
    - - Role - -
    - {ROLE_OPTIONS.map((opt) => ( - - ))} -
    -
    - {error && } - - ); -} - -interface EmailEditorProps { - initialEmail: string; - onCancel: () => void; - onSaved: (email: string) => void; -} - -/** - * Renders the inline editor of the signed-in user's email, which only they - * see. - */ -function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) { - const { authFetch } = useAuth(); - const form = useApiForm<{ email: string }>({ - defaultValues: { email: initialEmail }, - }); - const email = form.watch("email"); - const submitting = form.formState.isSubmitting; - - const onSubmit = form.submit(async ({ email }) => { - await expectOk>( - await authFetch(`${API_URL}/api/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - { email: email.trim() } satisfies UpdateUserRequest, - ), - }), - ); - onSaved(email.trim()); - }); - - return ( - -
    - { - if (e.key === "Escape") onCancel(); - }} - disabled={submitting} - autoFocus - /> -
    - Saving…} - disabled={!email.trim()} - > - Save - - -
    - - -
    - ); -} - -interface DescriptionEditorProps { - initialDescription: string; - onCancel: () => void; - onSaved: (description: string | undefined) => void; -} - -/** - * Renders the inline editor of the signed-in user's description, in Markdown. - * Other browsers see the change through the socket's `user_updated` event. - */ -function DescriptionEditor( - { initialDescription, onCancel, onSaved }: DescriptionEditorProps, -) { - const { authFetch } = useAuth(); - const form = useApiForm<{ description: string }>({ - defaultValues: { description: initialDescription }, - }); - const description = form.watch("description"); - const submitting = form.formState.isSubmitting; - - const onSubmit = form.submit(async ({ description }) => { - const trimmed = description.trim(); - // Sent even when empty: left out, the server would keep the old one. - await expectOk>( - await authFetch(`${API_URL}/api/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - { description: trimmed } satisfies UpdateUserRequest, - ), - }), - ); - onSaved(trimmed || undefined); - }); - - return ( - -
    - ( - void onSubmit()} - onCancel={submitting ? undefined : onCancel} - placeholder={t`Who am I?`} - autoResize - maxLength={VALIDATION.USER_DESCRIPTION_MAX} - /> - )} - /> -
    - Saving…} - disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX} - > - Save - - - -
    - -
    - ); -} diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 10d49c1f..7b6560ee 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -5,150 +5,41 @@ * @module */ -import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { API_URL } from "../config/api.ts"; -import type { Dump } from "../../shared/dump.ts"; -import { deserializeDump } from "../utils/shared/deserialize.ts"; -import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; -import { useWS } from "../hooks/ws/useWS.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; -import { useFading } from "../hooks/feed/useFading.ts"; +import { useUpvotedList } from "../hooks/dump/useUpvotedList.ts"; import { useUserDumpFeed } from "../hooks/dump/useUserDumpFeed.ts"; -import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { DumpCardList } from "../components/dump/DumpCardList.tsx"; +import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; /** * Renders `/users/:username/upvoted`, for anyone. The dumps come from - * `useUserDumpFeed`. The socket's vote events keep the list current: a dump - * the user upvotes is fetched and prepended, and one whose vote they withdraw - * fades out (`useFading`) unless they vote again within 2 seconds. + * `useUserDumpFeed`, and `useUpvotedList` keeps them in step with the user's + * votes. */ export function UserUpvoted(): React.JSX.Element { const { username } = useParams(); - const { user: me, token } = useAuth(); - const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS(); useDocumentTitle(username ?? null); - // The dumps still upvoted. A loaded dump outside it shows only while it - // fades. - const [votedIds, setVotedIds] = useState>(new Set()); - const { fading, startFading, cancelFading, cancelAll } = useFading(); - const prevMyVotesRef = useRef | null>(null); - - const onItemsAppended = useCallback((newItems: Dump[]) => { - setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)])); - }, []); - - const { state, setState, setItems, sentinelRef } = useUserDumpFeed( + const { state, setItems, sentinelRef } = useUserDumpFeed( username, "votes", `feed:user-upvoted-full:${username ?? ""}`, - { onItemsAppended }, ); useDumpListSync(setItems); - - const profileUserId = state.status === "loaded" ? state.profileUser.id : null; - - const [prevUsername, setPrevUsername] = useState(username); - if (prevUsername !== username) { - setPrevUsername(username); - setVotedIds(new Set()); - } - useEffect(() => { - cancelAll(); - prevMyVotesRef.current = null; - }, [username, cancelAll]); - - const [prevStateStatus, setPrevStateStatus] = useState(state.status); - const [prevStateItems, setPrevStateItems] = useState( - state.status === "loaded" ? state.items : null, - ); - const currentItems = state.status === "loaded" ? state.items : null; - if ( - prevStateStatus !== state.status || - prevStateItems !== currentItems - ) { - setPrevStateStatus(state.status); - setPrevStateItems(currentItems); - if (state.status === "loaded") { - setVotedIds(new Set(state.items.map((d) => d.id))); - } - } - - useEffect(() => { - if (!profileUserId || me?.id !== profileUserId) return; - if (prevMyVotesRef.current === null) { - // 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. - prevMyVotesRef.current = new Set(myVotes); - return; - } - const prev = prevMyVotesRef.current; - // Votes cast or withdrawn since, in any of the user's tabs. - setVotedIds((ids) => { - const next = new Set(ids); - for (const id of prev) if (!myVotes.has(id)) next.delete(id); - for (const id of myVotes) next.add(id); - return next; - }); - for (const id of prev) if (!myVotes.has(id)) startFading(id); - for (const id of myVotes) if (!prev.has(id)) cancelFading(id); - prevMyVotesRef.current = new Set(myVotes); - }, [myVotes, me, profileUserId, startFading, cancelFading]); - - useEffect(() => { - if (!lastVoteEvent || !profileUserId) return; - const { dumpId, voterId, action } = lastVoteEvent; - if (voterId !== profileUserId) return; - - if (action === "remove") { - // Both updates must land in the same render, or the card unmounts - // before its fade starts. - // eslint-disable-next-line react-hooks/set-state-in-effect - setVotedIds((prev) => { - const n = new Set(prev); - n.delete(dumpId); - return n; - }); - startFading(dumpId); - } else { - setVotedIds((prev) => new Set([...prev, dumpId])); - cancelFading(dumpId); - // Signed in, or a private dump the viewer owns can't be fetched. - fetch(`${API_URL}/api/dumps/${dumpId}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }) - .then((r) => r.json()) - .then((body) => { - if (!body.success) return; - const dump = deserializeDump(body.data); - setState((s) => { - if (s.status !== "loaded" || s.items.some((d) => d.id === dumpId)) { - return s; - } - return { ...s, items: [dump, ...s.items] }; - }); - }) - .catch(() => {}); - } - }, [ - lastVoteEvent, - profileUserId, - startFading, - cancelFading, - setState, - token, - ]); + const { cardClassName } = useUpvotedList({ + profileUserId: state.status === "loaded" ? state.profileUser.id : null, + setItems, + }); if (state.status === "loading") { return ( @@ -174,9 +65,6 @@ export function UserUpvoted(): React.JSX.Element { } const { profileUser, items: votes, hasMore, loadingMore } = state; - const visibleDumps = votes.filter((d) => - votedIds.has(d.id) || d.id in fading - ); return ( @@ -185,7 +73,7 @@ export function UserUpvoted(): React.JSX.Element { profileUser={profileUser} title={t`Upvoted`} /> - {visibleDumps.length === 0 + {votes.length === 0 ? (

    Nothing here yet. @@ -193,40 +81,12 @@ export function UserUpvoted(): React.JSX.Element { ) : ( <> -

      - {visibleDumps.map((dump) => { - const phase = fading[dump.id]; - const extraCls = phase === "cooldown" - ? "dump-card--fading" - : phase === "dismissing" - ? "dump-card--dismissing" - : undefined; - return ( - - ); - })} -
    - {hasMore &&
    } - {loadingMore && ( -

    - Loading more… -

    - )} - {!hasMore && ( -

    - You've reached the end. -

    - )} + + )} diff --git a/src/test/hooks/feed/useFading.test.tsx b/src/test/hooks/feed/useFading.test.tsx index 6a1989a4..ef413016 100644 --- a/src/test/hooks/feed/useFading.test.tsx +++ b/src/test/hooks/feed/useFading.test.tsx @@ -52,4 +52,17 @@ describe("useFading", () => { act(() => result.current.cancelAll()); expect(result.current.fading).toEqual({}); }); + + test("onFaded hears of a fade that ended, not of a cancelled one", () => { + const onFaded = vi.fn(); + const { result } = renderHook(() => useFading(onFaded)); + act(() => { + result.current.startFading("a"); + result.current.startFading("b"); + }); + act(() => vi.advanceTimersByTime(2100)); + act(() => result.current.cancelFading("b")); + act(() => vi.advanceTimersByTime(250)); + expect(onFaded.mock.calls).toEqual([["a"]]); + }); }); diff --git a/src/test/hooks/shared/useFetchState.test.tsx b/src/test/hooks/shared/useFetchState.test.tsx new file mode 100644 index 00000000..0ae348ae --- /dev/null +++ b/src/test/hooks/shared/useFetchState.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useFetchState } from "../../../hooks/shared/useFetchState.ts"; +import { deferred } from "../../fetch.ts"; + +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), + { initialProps: { enabled: false } }, + ); + expect(load).not.toHaveBeenCalled(); + expect(result.current.state).toEqual({ status: "loading" }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.state).toEqual({ status: "loaded", data: 1 }) + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + expect(load).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ status: "loaded", data: 1 }); + }); + + test("turning it off doesn't stop a load under way", async () => { + const d = deferred(); + const { result, rerender } = renderHook( + ({ enabled }) => useFetchState("a", () => d.promise, enabled), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + act(() => d.resolve(2)); + await waitFor(() => + expect(result.current.state).toEqual({ status: "loaded", data: 2 }) + ); + }); + + test("a new key starts over and aborts the last key's load", async () => { + const signals: AbortSignal[] = []; + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal); + return signals.length === 1 + ? new Promise(() => {}) + : Promise.resolve("b"); + }); + const { result, rerender } = renderHook( + ({ key }) => useFetchState(key, load), + { initialProps: { key: "a" } }, + ); + rerender({ key: "b" }); + expect(signals[0].aborted).toBe(true); + await waitFor(() => + expect(result.current.state).toEqual({ status: "loaded", data: "b" }) + ); + }); + + test("an undefined key loads nothing", () => { + const load = vi.fn(() => Promise.resolve(1)); + renderHook(() => useFetchState(undefined, load)); + expect(load).not.toHaveBeenCalled(); + }); + + test("a failed load shows its message", async () => { + const { result } = renderHook(() => + useFetchState("a", () => Promise.reject(new Error("Nope"))) + ); + await waitFor(() => + expect(result.current.state).toEqual({ status: "error", error: "Nope" }) + ); + }); + + test("setData edits the loaded data, and nothing before", async () => { + const d = deferred(); + const { result } = renderHook(() => useFetchState("a", () => d.promise)); + act(() => result.current.setData((xs) => [...xs, 9])); + expect(result.current.state).toEqual({ status: "loading" }); + act(() => d.resolve([1])); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + act(() => result.current.setData((xs) => [...xs, 2])); + expect(result.current.state).toEqual({ status: "loaded", data: [1, 2] }); + }); +}); diff --git a/src/test/utils/feed/pagedList.test.ts b/src/test/utils/feed/pagedList.test.ts new file mode 100644 index 00000000..92efea08 --- /dev/null +++ b/src/test/utils/feed/pagedList.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { API_URL } from "../../../config/api.ts"; +import { + fetchFirstPage, + firstPage, + restoredList, + withFirst, +} from "../../../utils/feed/pagedList.ts"; +import { calls, fail, route } from "../../fetch.ts"; +import { page } from "../../fixtures.ts"; + +describe("pagedList", () => { + test("fetchFirstPage asks for page 1 and deserializes it", async () => { + route("GET /api/things", () => page([{ id: "a" }], true)); + const list = await fetchFirstPage( + `${API_URL}/api/things`, + (w: { id: string }) => ({ ...w, seen: true }), + ); + expect(list).toEqual({ + items: [{ id: "a", seen: true }], + hasMore: true, + page: 1, + loadingMore: false, + }); + 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 () => { + 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); + }); + + test("restoredList carries on from the cached page", () => { + expect(restoredList({ items: [1], hasMore: true, page: 3 })).toEqual({ + items: [1], + hasMore: true, + page: 3, + loadingMore: false, + }); + }); + + test("withFirst puts a new item first, once", () => { + const a = { id: "a" }; + const b = { id: "b" }; + expect(withFirst([a], b)).toEqual([b, a]); + const items = [a, b]; + expect(withFirst(items, { id: "b" })).toBe(items); + }); +}); diff --git a/src/utils/feed/pagedList.ts b/src/utils/feed/pagedList.ts new file mode 100644 index 00000000..d222b9dd --- /dev/null +++ b/src/utils/feed/pagedList.ts @@ -0,0 +1,64 @@ +/** + * A list of which the first pages are loaded, as the pages holding several + * such lists keep each one. + * + * @module + */ + +import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; +import type { PaginatedData, Wire } from "../../../shared/api.ts"; +import { dataOr } 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. + */ +export interface PagedList { + items: T[]; + hasMore: boolean; + /** The last page loaded, from which loading more carries on. */ + page: number; + loadingMore: boolean; +} + +/** Returns the list holding only its first page, `items`. */ +export function firstPage(items: T[], hasMore: boolean): PagedList { + return { items, hasMore, page: 1, loadingMore: false }; +} + +/** Returns the list as `useFeedCache`'s `cached` restores it. */ +export function restoredList( + { items, hasMore, page }: { items: T[]; hasMore: boolean; page: number }, +): PagedList { + return { items, hasMore, page, loadingMore: false }; +} + +const EMPTY_PAGE: PaginatedData = { items: [], hasMore: false }; + +/** + * Returns the first page of the list at `url`, which takes `page` and `limit` + * in its query, or an empty list when the server refuses it. The profile pages + * load their lists with it beside the user, whose failure alone fails the + * page. + * + * @throws {Error} Offline, when aborted, and when the body isn't an envelope. + */ +export async function fetchFirstPage( + url: string, + deserialize: (wire: Wire) => T, + init?: RequestInit, +): Promise> { + const { items, hasMore } = await dataOr>>( + await fetch(`${url}?page=1&limit=${DEFAULT_PAGE_SIZE}`, init), + EMPTY_PAGE, + ); + return firstPage(items.map(deserialize), hasMore); +} + +/** + * Returns `items` with `item` first, or `items` as they are when it is already + * there, as when a list's own echo of a new item arrives after it. + */ +export function withFirst(items: T[], item: T): T[] { + return items.some((i) => i.id === item.id) ? items : [item, ...items]; +} diff --git a/src/utils/shared/api.ts b/src/utils/shared/api.ts index 33ea0308..6587ae07 100644 --- a/src/utils/shared/api.ts +++ b/src/utils/shared/api.ts @@ -46,3 +46,16 @@ export async function expectOk(res: Response): Promise { if (!body.success) throw new Error(body.error.message); return body.data; } + +/** + * 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. + * + * @throws {Error} When the body isn't JSON or isn't an envelope. + */ +export async function dataOr(res: Response, fallback: T): Promise { + const body = parseAPIResponse(await res.json()); + return body.success ? body.data : fallback; +} diff --git a/src/utils/shared/fetchPublicUser.ts b/src/utils/shared/fetchPublicUser.ts new file mode 100644 index 00000000..501727b6 --- /dev/null +++ b/src/utils/shared/fetchPublicUser.ts @@ -0,0 +1,28 @@ +/** + * Fetches the user a profile page is about. + * + * @module + */ + +import { API_URL } from "../../config/api.ts"; +import type { Wire } from "../../../shared/api.ts"; +import type { PublicUser } from "../../../shared/user.ts"; +import { expectOk } from "./api.ts"; +import { deserializePublicUser } from "./deserialize.ts"; + +/** + * Returns `GET /api/users/:username`, for the profile and its full lists, + * which fail as a whole when it does. It is fetched afresh even when their + * lists come from `useFeedCache`. + * + * @throws {Error} "User not found" on a 404, the server's message on another + * refusal, and the fetch's own error offline or when aborted. + */ +export async function fetchPublicUser( + username: string, + signal: AbortSignal, +): Promise { + const res = await fetch(`${API_URL}/api/users/${username}`, { signal }); + if (res.status === 404) throw new Error("User not found"); + return deserializePublicUser(await expectOk>(res)); +} diff --git a/src/utils/shared/loadState.ts b/src/utils/shared/loadState.ts new file mode 100644 index 00000000..79fd9bae --- /dev/null +++ b/src/utils/shared/loadState.ts @@ -0,0 +1,16 @@ +/** + * The state of something a component loads from the API, which `ListState` + * renders while it isn't loaded. + * + * @module + */ + +/** + * Held by a page or hook for what it fetches, as `useFetchState` returns it, + * and passed to `ListState`. `L` is what the loaded state carries besides its + * status, as `{ data: T }` for `useFetchState` or a feed's page fields. + */ +export type LoadState = + | { status: "loading" } + | { status: "error"; error: string } + | ({ status: "loaded" } & L);