v3: the profile page is split by one rule, and what it re-invented is shared across the app — UserPublicProfile was 1,949 lines because it held its data, six tabs, seven subcomponents and hand-rolled copies of things the app already had or repeated elsewhere. The page now only picks the tab and puts the pieces together (253 lines); useProfile holds the user and the three lists with their live sync, cache and scroll; each tab and header piece is one file under components/profile/; the followed and invitees tabs load on first open through the new useFetchState, keyed by username, which keeps them across tab switches. Every piece taken out replaces its copies: DumpCardList, which reads the votes from WSContext, is every list of dump cards (the index feeds, followed, search, related dumps, UserDumps, UserUpvoted, the profile), so Index no longer threads vote props into the feeds; PlaylistCardList every list of playlist cards; FeedFooter the eight sentinel/loading-more/end blocks; ListState the loading/error/empty chains; Segmented the settings toggles, RoleSwitcher and the playlist edit form, with SegmentedField built on it; LoadMoreButton the three load-more buttons; NewDumpButton UserDumps' and the profile's; useScrollRestore four copies of the scroll restore, and the profile uses useScrollSave instead of saving its whole cache on every scroll. useFading takes an onFaded callback, which replaces PlaylistDetail's copy of its timers; useUpvotedList is the vote tracking UserUpvoted and the profile each had, and a withdrawn upvote now leaves the list and the cache once faded (it was only hidden, and came back after a tab switch). usePlaylistMemberships is the membership load and toggle AddToPlaylistModal and DumpCreateModal each had. pagedList.ts (PagedList, firstPage, restoredList, fetchFirstPage, withFirst), fetchPublicUser and dataOr are shared by the profile, UserPlaylists and useUserDumpFeed; a refused side list still shows empty, as documented, and only the user fails the page. FollowedFeed's two sections are one local hook, and its readPage is expectOk. A card is its owner's when the item is the signed-in user's, everywhere: the profile's upvoted column marked every dump as the owner's on one's own profile. No message changed; the catalogs only move their references. 643 frontend tests pass (11 new for useFetchState, useFading's onFaded and pagedList), and tsc, eslint, doc:lint, deno check and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
51
src/components/dump/DumpCardList.tsx
Normal file
51
src/components/dump/DumpCardList.tsx
Normal file
@@ -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 (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
className={cardClassName?.(dump)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
33
src/components/dump/NewDumpButton.tsx
Normal file
33
src/components/dump/NewDumpButton.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="new-item-toggle"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
+<span className="btn-new-label">
|
||||
<Trans>New dump</Trans>
|
||||
</span>
|
||||
</button>
|
||||
{open && <DumpCreateModal onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<PlaylistMembership[]>([]);
|
||||
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<PlaylistMembership>[]).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(
|
||||
|
||||
<PlaylistMembershipPanel
|
||||
dumpId={createdDump?.id ?? ""}
|
||||
memberships={memberships}
|
||||
loading={playlistsLoading}
|
||||
onToggle={toggleMembership}
|
||||
onPlaylistCreated={(membership) =>
|
||||
setMemberships((prev) => [membership, ...prev])}
|
||||
memberships={playlists.memberships}
|
||||
loading={playlists.loading}
|
||||
onToggle={playlists.toggle}
|
||||
onPlaylistCreated={playlists.add}
|
||||
/>
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
@@ -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<HTMLDivElement>;
|
||||
/**
|
||||
* Counts pushed by the socket. A dump missing here shows its fetched
|
||||
* `voteCount`.
|
||||
*/
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
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 (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="dump-feed">
|
||||
{sorted.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={() => sorted.length === 0}
|
||||
empty={
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ hasMore, loadingMore }) => (
|
||||
<>
|
||||
<DumpCardList dumps={sorted} />
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!hasMore && sorted.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/components/feed/FeedFooter.tsx
Normal file
44
src/components/feed/FeedFooter.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 && <div ref={sentinelRef} />}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<PaginatedData<Wire<Dump>>> {
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
throw new Error(body.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return body.data as PaginatedData<Wire<Dump>>;
|
||||
}
|
||||
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/<section>`, 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<string, number>;
|
||||
myVotes: Set<string>;
|
||||
/** `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<Dump>(
|
||||
`feed:followed-${section}:${categorySlug ?? "all"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [state, setState] = useState<DumpFeedState>(() =>
|
||||
cached
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }),
|
||||
[],
|
||||
);
|
||||
useDumpListSync(setItems);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || state.status !== "loading") return;
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/${section}?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then(expectOk<PaginatedData<Wire<Dump>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
setState({ status: "error", error: friendlyFetchError(err) })
|
||||
);
|
||||
}, [token, state.status, section, catQuery]);
|
||||
|
||||
// Only the list is kept here: `Index` saves and restores the page's scroll
|
||||
// for every tab, this one included.
|
||||
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<PaginatedData<Wire<Dump>>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [state, token, section, catQuery]);
|
||||
|
||||
return { state, loadMore };
|
||||
}
|
||||
|
||||
interface FollowedSubFeedProps {
|
||||
state: FeedState;
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
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 (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <ErrorCard title={t`Failed to load`} message={state.error} />;
|
||||
}
|
||||
|
||||
if (state.dumps.length === 0) {
|
||||
return <p className="index-status">{emptyMessage}</p>;
|
||||
}
|
||||
function FollowedSubFeed(
|
||||
{ state, emptyMessage, onLoadMore }: FollowedSubFeedProps,
|
||||
) {
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
onLoadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="dump-feed">
|
||||
{state.dumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user.id === dump.userId}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ dumps }) => dumps.length === 0}
|
||||
empty={<p className="index-status">{emptyMessage}</p>}
|
||||
>
|
||||
{({ dumps, hasMore, loadingMore }) => (
|
||||
<>
|
||||
<DumpCardList dumps={dumps} />
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{state.hasMore && <div ref={sentinelRef} />}
|
||||
{state.loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!state.hasMore && state.dumps.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Dump>(
|
||||
`feed:followed-users:${scope}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
||||
Dump
|
||||
>(
|
||||
`feed:followed-playlists:${scope}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const [usersState, setUsersState] = useState<FeedState>(() =>
|
||||
cachedUsers
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cachedUsers.items,
|
||||
hasMore: cachedUsers.hasMore,
|
||||
page: cachedUsers.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
|
||||
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
|
||||
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<FollowedSection>("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 (
|
||||
<div className="followed-feed">
|
||||
<TabBar
|
||||
@@ -366,27 +203,17 @@ export function FollowedFeed({
|
||||
|
||||
{section === "users" && (
|
||||
<FollowedSubFeed
|
||||
state={usersState}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
state={users.state}
|
||||
emptyMessage={t`Follow some users to see their dumps here.`}
|
||||
onLoadMore={loadMoreUsers}
|
||||
onLoadMore={users.loadMore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "playlists" && (
|
||||
<FollowedSubFeed
|
||||
state={playlistsState}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
state={playlists.state}
|
||||
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
||||
onLoadMore={loadMorePlaylists}
|
||||
onLoadMore={playlists.loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
/* Each block is its own grid, with a themable <hr> between them. Inside
|
||||
a grid the <hr> would be stretched to a 9rem row, while out here a
|
||||
theme can shrink it to nothing. */
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={() => blocks.length === 0}
|
||||
empty={
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
</p>
|
||||
}
|
||||
<div className="journal-feed">
|
||||
{blocks.map((block, index) => (
|
||||
<Fragment key={block.key}>
|
||||
{index > 0 && <hr className="journal-break" />}
|
||||
<ul className="journal-grid">
|
||||
{block.entries.map(({ dump, shape }) => (
|
||||
<JournalCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
shape={shape}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
>
|
||||
{({ hasMore, loadingMore }) => (
|
||||
<>
|
||||
{
|
||||
/* Each block is its own grid, with a themable <hr> between them.
|
||||
Inside a grid the <hr> would be stretched to a 9rem row, while out
|
||||
here a theme can shrink it to nothing. */
|
||||
}
|
||||
<div className="journal-feed">
|
||||
{blocks.map((block, index) => (
|
||||
<Fragment key={block.key}>
|
||||
{index > 0 && <hr className="journal-break" />}
|
||||
<ul className="journal-grid">
|
||||
{block.entries.map(({ dump, shape }) => (
|
||||
<JournalCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
shape={shape}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!hasMore && blocks.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<V> {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to {@link SegmentedField} by a form, or by {@link VisibilityToggle}.
|
||||
*/
|
||||
@@ -34,9 +29,8 @@ export interface SegmentedFieldProps<T extends FieldValues, V> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T extends FieldValues, V>({
|
||||
name,
|
||||
@@ -53,25 +47,24 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="visibility-toggle">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={field.value === opt.value ? "active" : ""}
|
||||
disabled={disabled}
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Segmented
|
||||
options={options}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Public/Private options, true for public. */
|
||||
const VISIBILITY_OPTIONS: SegmentedOption<boolean>[] = [
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
{ value: false, label: <Trans>Private</Trans> },
|
||||
];
|
||||
|
||||
/** Passed to {@link VisibilityToggle} by a dump or playlist form. */
|
||||
export interface VisibilityToggleProps<T extends FieldValues> {
|
||||
/** A boolean field, true for public. */
|
||||
@@ -95,10 +88,18 @@ export function VisibilityToggle<T extends FieldValues>({
|
||||
name={name}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
{ value: false, label: <Trans>Private</Trans> },
|
||||
]}
|
||||
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<SegmentedProps<boolean>, "options">,
|
||||
): React.JSX.Element {
|
||||
return <Segmented options={VISIBILITY_OPTIONS} {...props} />;
|
||||
}
|
||||
|
||||
@@ -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<PlaylistMembership[]>([]);
|
||||
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<PlaylistMembership>[]).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 (
|
||||
<Modal title={t`Add to playlist`} onClose={onClose}>
|
||||
@@ -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}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
42
src/components/playlist/PlaylistCardList.tsx
Normal file
42
src/components/playlist/PlaylistCardList.tsx
Normal file
@@ -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 (
|
||||
<ul className="dump-feed">
|
||||
{playlists.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
playlist={p}
|
||||
isOwner={!!user && user.id === p.userId}
|
||||
onDelete={onDelete && (() => onDelete(p))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
103
src/components/profile/DescriptionEditor.tsx
Normal file
103
src/components/profile/DescriptionEditor.tsx
Normal file
@@ -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<Wire<PublicUser>>(
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="profile-description-editor animate-in"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
onCancel={submitting ? undefined : onCancel}
|
||||
placeholder={t`Who am I?`}
|
||||
autoResize
|
||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="profile-description-actions">
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<FormError title={t`Failed to save`} />
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
90
src/components/profile/EmailEditor.tsx
Normal file
90
src/components/profile/EmailEditor.tsx
Normal file
@@ -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<Wire<PublicUser>>(
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
className="profile-email-input"
|
||||
{...form.register("email")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="profile-email-actions">
|
||||
<SubmitButton
|
||||
className="profile-email-btn profile-email-btn--save"
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!email.trim()}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-email-btn profile-email-btn--cancel"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<FormError title={t`Failed to save`} />
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
91
src/components/profile/ProfileDescription.tsx
Normal file
91
src/components/profile/ProfileDescription.tsx
Normal file
@@ -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<HTMLDivElement>(editing);
|
||||
|
||||
if (!description && !isOwnProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="profile-description" ref={ref}>
|
||||
{editing
|
||||
? (
|
||||
<DescriptionEditor
|
||||
initialDescription={description ?? ""}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSaved={(saved) => {
|
||||
onSaved(saved);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className={`profile-description-view${
|
||||
isOwnProfile ? " profile-description-view--editable" : ""
|
||||
}`}
|
||||
onClick={isOwnProfile ? () => 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
|
||||
? (
|
||||
<Markdown className="profile-description-text">
|
||||
{description}
|
||||
</Markdown>
|
||||
)
|
||||
: (
|
||||
<div className="profile-description-empty">
|
||||
<Trans>Who am I?</Trans>
|
||||
</div>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/profile/ProfileDumpsTab.tsx
Normal file
71
src/components/profile/ProfileDumpsTab.tsx
Normal file
@@ -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<Dump>;
|
||||
votes: PagedList<Dump>;
|
||||
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 (
|
||||
<div className="profile-columns">
|
||||
<ProfileSection
|
||||
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
||||
actions={isOwnProfile && <NewDumpButton />}
|
||||
viewAllHref={dumps.items.length > 0
|
||||
? `/users/${username}/dumps`
|
||||
: undefined}
|
||||
>
|
||||
{dumps.items.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: <DumpCardList dumps={dumps.items} />}
|
||||
</ProfileSection>
|
||||
<ProfileSection
|
||||
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
|
||||
viewAllHref={votes.items.length > 0
|
||||
? `/users/${username}/upvoted`
|
||||
: undefined}
|
||||
>
|
||||
{votes.items.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<DumpCardList
|
||||
dumps={votes.items}
|
||||
cardClassName={upvotedCardClassName}
|
||||
/>
|
||||
)}
|
||||
</ProfileSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/profile/ProfileFollowedTab.tsx
Normal file
119
src/components/profile/ProfileFollowedTab.tsx
Normal file
@@ -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<PublicUser>;
|
||||
playlists: PagedList<Playlist>;
|
||||
}
|
||||
|
||||
/** Passed to {@link ProfileFollowedTab} by `UserPublicProfile`. */
|
||||
export interface ProfileFollowedTabProps {
|
||||
username: string;
|
||||
state: FetchState<ProfileFollowed>;
|
||||
onLoadMoreUsers: () => void;
|
||||
}
|
||||
|
||||
/** Renders a followed user, with a button to follow or unfollow them. */
|
||||
function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
return (
|
||||
<li className="followed-user-card">
|
||||
<Link to={`/users/${user.username}`} className="followed-user-card-link">
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={36}
|
||||
version={user.updatedAt?.getTime()}
|
||||
/>
|
||||
<span className="followed-user-card-name">@{user.username}</span>
|
||||
</Link>
|
||||
<FollowUserButton
|
||||
targetUserId={user.id}
|
||||
targetUsername={user.username}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="profile-columns">
|
||||
<ProfileSection title={<Trans>Following</Trans>}>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.users.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Not following anyone yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data: { users } }) => (
|
||||
<>
|
||||
<ul className="followed-user-list">
|
||||
{users.items.map((u) => (
|
||||
<FollowedUserCard
|
||||
key={u.id}
|
||||
user={u}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{users.hasMore && (
|
||||
<LoadMoreButton
|
||||
loadingMore={users.loadingMore}
|
||||
onClick={onLoadMoreUsers}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection
|
||||
title={<Trans>Followed playlists</Trans>}
|
||||
viewAllHref={state.status === "loaded" && state.data.playlists.hasMore
|
||||
? `/users/${username}/playlists`
|
||||
: undefined}
|
||||
>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.playlists.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>No followed playlists yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data }) => <PlaylistCardList playlists={data.playlists.items} />}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/components/profile/ProfileHeader.tsx
Normal file
182
src/components/profile/ProfileHeader.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [emailEditing, setEmailEditing] = useState(false);
|
||||
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<Wire<SelfUser>>(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 (
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-wrapper">
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={128}
|
||||
version={user.updatedAt?.getTime()}
|
||||
/>
|
||||
{isOwnProfile && (
|
||||
<label className="avatar-change-overlay" title={t`Change avatar`}>
|
||||
{uploading ? "…" : "✎"}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
onChange={handleAvatarUpload}
|
||||
disabled={uploading}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-info" ref={infoRef}>
|
||||
<div className="profile-info-scroll">
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">{user.username}</h1>
|
||||
<RoleChip role={user.role} />
|
||||
</div>
|
||||
{user.invitedByUsername
|
||||
? (
|
||||
<p className="profile-invited-by">
|
||||
<Trans>invited by</Trans>{" "}
|
||||
<Link
|
||||
to={`/users/${user.invitedByUsername}`}
|
||||
className="profile-invited-by-link"
|
||||
>
|
||||
@{user.invitedByUsername}
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="profile-invited-by profile-invited-by--founding">
|
||||
O.G.
|
||||
</p>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
emailEditing
|
||||
? (
|
||||
<EmailEditor
|
||||
initialEmail={me?.email ?? ""}
|
||||
onCancel={() => setEmailEditing(false)}
|
||||
onSaved={handleEmailSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => 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…`}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to update avatar`}
|
||||
message={avatarError}
|
||||
/>
|
||||
)}
|
||||
{!isOwnProfile && (
|
||||
<FollowUserButton
|
||||
targetUserId={user.id}
|
||||
targetUsername={user.username}
|
||||
/>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<ProfileOwnActions>
|
||||
<button type="button" className="btn-border" onClick={logout}>
|
||||
<Trans>Log out</Trans>
|
||||
</button>
|
||||
</ProfileOwnActions>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/components/profile/ProfileInviteesTab.tsx
Normal file
90
src/components/profile/ProfileInviteesTab.tsx
Normal file
@@ -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 (
|
||||
<ul className="invite-tree">
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id} className="invite-tree-node">
|
||||
<Link
|
||||
to={`/users/${node.username}`}
|
||||
className="invite-tree-user"
|
||||
>
|
||||
<Avatar
|
||||
userId={node.id}
|
||||
username={node.username}
|
||||
hasAvatar={!!node.avatarMime}
|
||||
size={24}
|
||||
/>
|
||||
@{node.username}
|
||||
</Link>
|
||||
{node.children.length > 0 && <InviteTreeList nodes={node.children} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<Invitee[]>;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ProfileSection title={<Trans>Invitees</Trans>}>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.length === 0}
|
||||
empty={
|
||||
<p className="empty-state invite-tree-empty">
|
||||
<Trans>No invitees yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{() => <InviteTreeList nodes={nodes} />}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
55
src/components/profile/ProfileManageTab.tsx
Normal file
55
src/components/profile/ProfileManageTab.tsx
Normal file
@@ -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 && (
|
||||
<ProfileSection title={<Trans>User management</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<RoleSwitcher
|
||||
userId={user.id}
|
||||
currentRole={user.role}
|
||||
onSaved={onRoleSaved}
|
||||
/>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
)}
|
||||
{isOwnProfile && canManageCategories && (
|
||||
<ProfileSection title={<Trans>Categories</Trans>}>
|
||||
<CategoryManager />
|
||||
</ProfileSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
src/components/profile/ProfileOwnActions.tsx
Normal file
119
src/components/profile/ProfileOwnActions.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ref = useHeightTransition<HTMLDivElement>(
|
||||
(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 (
|
||||
<div className="profile-own-actions" ref={ref}>
|
||||
<div className="profile-own-actions-row">
|
||||
<button
|
||||
type="button"
|
||||
className="invite-btn"
|
||||
onClick={toggle}
|
||||
disabled={pending}
|
||||
aria-expanded={inviteUrl !== null}
|
||||
>
|
||||
<Trans>+ Invite someone</Trans>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
{inviteUrl && (
|
||||
<div
|
||||
className="invite-panel animate-in"
|
||||
role="group"
|
||||
aria-label={t`Invite link`}
|
||||
>
|
||||
<input
|
||||
className="invite-url"
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteUrl}
|
||||
aria-label={t`Invite link`}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`invite-copy-btn${
|
||||
copied ? " invite-copy-btn--done" : ""
|
||||
}`}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t`Copied!` : t`Copy`}
|
||||
</button>
|
||||
<span className="invite-panel-hint">
|
||||
<Trans>Single use, valid for 7 days.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="invite-panel invite-panel--error animate-in">
|
||||
<ErrorCard title={t`Failed to generate invite`} message={error} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/profile/ProfilePlaylistsTab.tsx
Normal file
57
src/components/profile/ProfilePlaylistsTab.tsx
Normal file
@@ -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<Playlist>;
|
||||
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 (
|
||||
<ProfileSection
|
||||
title={
|
||||
<Trans>
|
||||
Playlists ({playlists.items.length}
|
||||
{playlists.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
}
|
||||
actions={isOwnProfile && (
|
||||
<NewPlaylistButton
|
||||
onCreated={(p) => setPlaylists((items) => withFirst(items, p))}
|
||||
/>
|
||||
)}
|
||||
viewAllHref={playlists.items.length > 0
|
||||
? `/users/${username}/playlists`
|
||||
: undefined}
|
||||
>
|
||||
{playlists.items.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: <PlaylistCardList playlists={playlists.items} />}
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
48
src/components/profile/ProfileSection.tsx
Normal file
48
src/components/profile/ProfileSection.tsx
Normal file
@@ -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 (
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">{title}</h2>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
{viewAllHref && (
|
||||
<Link to={viewAllHref} className="profile-view-all">
|
||||
<Trans>View all →</Trans>
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
128
src/components/profile/ProfileSettingsTab.tsx
Normal file
128
src/components/profile/ProfileSettingsTab.tsx
Normal file
@@ -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<StyleName>[] = [
|
||||
{ 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<ColorScheme>[] = [
|
||||
{ value: "auto", label: <Trans>Auto</Trans> },
|
||||
{ value: "light", label: <Trans>Light</Trans> },
|
||||
{ value: "dark", label: <Trans>Dark</Trans> },
|
||||
];
|
||||
|
||||
const FEED_TAB_OPTIONS: SegmentedOption<FeedTab>[] = [
|
||||
{ value: "hot", label: <Trans>Hot</Trans> },
|
||||
{ value: "new", label: <Trans>New</Trans> },
|
||||
{ value: "journal", label: <Trans>Journal</Trans> },
|
||||
{ value: "followed", label: <Trans>Followed</Trans> },
|
||||
];
|
||||
|
||||
const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
|
||||
{ value: true, label: <Trans>On</Trans> },
|
||||
{ value: false, label: <Trans>Off</Trans> },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<>
|
||||
<ProfileSection title={<Trans>Account</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<SettingRow label={<Trans>Password</Trans>}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={() => setChangePasswordOpen(true)}
|
||||
>
|
||||
<Trans>Change password…</Trans>
|
||||
</button>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
<ProfileSection title={<Trans>Appearance</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<SettingRow label={<Trans>Style</Trans>}>
|
||||
<Segmented
|
||||
options={STYLE_OPTIONS}
|
||||
value={style}
|
||||
onChange={setStyle}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={<Trans>Color scheme</Trans>}>
|
||||
<Segmented
|
||||
options={COLOR_SCHEME_OPTIONS}
|
||||
value={colorScheme}
|
||||
onChange={setColorScheme}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
<ProfileSection title={<Trans>Feeds</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<SettingRow label={<Trans>Default tab</Trans>}>
|
||||
<Segmented
|
||||
options={FEED_TAB_OPTIONS}
|
||||
value={defaultFeedTab}
|
||||
onChange={setDefaultFeedTab}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
<ProfileSection title={<Trans>Notifications</Trans>}>
|
||||
<div className="profile-appearance-grid">
|
||||
<SettingRow label={<Trans>Sounds</Trans>}>
|
||||
<Segmented
|
||||
options={ON_OFF_OPTIONS}
|
||||
value={soundsEnabled}
|
||||
onChange={(on) => {
|
||||
setSoundsEnabled(on);
|
||||
// Let the user hear what they just turned on.
|
||||
if (on) previewChime("notify");
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
{changePasswordOpen && (
|
||||
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
src/components/profile/RoleSwitcher.tsx
Normal file
95
src/components/profile/RoleSwitcher.tsx
Normal file
@@ -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<Role>[] = [
|
||||
{ value: "user", label: <Trans>User</Trans> },
|
||||
{ value: "moderator", label: <Trans>Moderator</Trans> },
|
||||
{ value: "admin", label: <Trans>Admin</Trans> },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span className={`profile-role-chip profile-role-chip--${role}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(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<Wire<PublicUser>>(res)).role,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(friendlyFetchError(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow label={<Trans>Role</Trans>}>
|
||||
<Segmented
|
||||
options={ROLE_OPTIONS}
|
||||
value={currentRole}
|
||||
onChange={changeRole}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
{error && <ErrorCard title={t`Failed to update role`} message={error} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/components/profile/SettingRow.tsx
Normal file
31
src/components/profile/SettingRow.tsx
Normal file
@@ -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 (
|
||||
<div className="profile-appearance-row">
|
||||
<span className="profile-appearance-label">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/shared/ListState.tsx
Normal file
46
src/components/shared/ListState.tsx
Normal file
@@ -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<L extends object> {
|
||||
state: LoadState<L>;
|
||||
/** 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<L extends object>(
|
||||
{ state, isEmpty, empty, children }: ListStateProps<L>,
|
||||
): ReactNode {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <ErrorCard title={t`Failed to load`} message={state.error} />;
|
||||
}
|
||||
return isEmpty(state) ? empty : children(state);
|
||||
}
|
||||
35
src/components/shared/LoadMoreButton.tsx
Normal file
35
src/components/shared/LoadMoreButton.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? <Trans>Loading…</Trans> : <Trans>Load more</Trans>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
51
src/components/shared/Segmented.tsx
Normal file
51
src/components/shared/Segmented.tsx
Normal file
@@ -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<V> {
|
||||
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<V> {
|
||||
options: SegmentedOption<V>[];
|
||||
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<V>(
|
||||
{ options, value, onChange, disabled, className }: SegmentedProps<V>,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<div className={`visibility-toggle${className ? ` ${className}` : ""}`}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={value === opt.value ? "active" : ""}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
</Link>
|
||||
))}
|
||||
{state.hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
<LoadMoreButton
|
||||
className="user-list-popover-load-more"
|
||||
loadingMore={state.loadingMore}
|
||||
onClick={handleLoadMore}
|
||||
disabled={state.loadingMore}
|
||||
>
|
||||
{state.loadingMore
|
||||
? <Trans>Loading…</Trans>
|
||||
: <Trans>Load more</Trans>}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
142
src/hooks/dump/useUpvotedList.ts
Normal file
142
src/hooks/dump/useUpvotedList.ts
Normal file
@@ -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<Set<string>>(new Set());
|
||||
const prevMyVotesRef = useRef<Set<string> | 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<Wire<Dump>>)
|
||||
.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 };
|
||||
}
|
||||
@@ -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<React.SetStateAction<UserDumpFeedState>>;
|
||||
/** 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<HTMLDivElement>;
|
||||
@@ -75,7 +61,6 @@ export function useUserDumpFeed(
|
||||
username: string | undefined,
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
options?: UseUserDumpFeedOptions,
|
||||
): UseUserDumpFeedResult {
|
||||
const { token } = useAuth();
|
||||
const { cached, saveState } = useFeedCache<Dump>(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<Wire<Dump>>
|
||||
: { 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 };
|
||||
}
|
||||
|
||||
@@ -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<string, FadeState>;
|
||||
/** 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<Record<string, FadeState>>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
// The running timer of each fading item, whichever phase it is in.
|
||||
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
84
src/hooks/playlist/usePlaylistMemberships.ts
Normal file
84
src/hooks/playlist/usePlaylistMemberships.ts
Normal file
@@ -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<void>;
|
||||
/** 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<Wire<PlaylistMembership>[]>)
|
||||
.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,
|
||||
};
|
||||
}
|
||||
233
src/hooks/profile/useProfile.ts
Normal file
233
src/hooks/profile/useProfile.ts
Normal file
@@ -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<Dump>;
|
||||
votes: PagedList<Dump>;
|
||||
playlists: PagedList<Playlist>;
|
||||
}
|
||||
|
||||
/** Returned by {@link useProfile} to `UserPublicProfile`. */
|
||||
export interface UseProfileResult {
|
||||
state: LoadState<ProfileData>;
|
||||
/** Whether the signed-in user is the profile's. False while it loads. */
|
||||
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<K extends ListKey>(
|
||||
s: LoadState<ProfileData>,
|
||||
key: K,
|
||||
fn: (items: ProfileData[K]["items"]) => ProfileData[K]["items"],
|
||||
): LoadState<ProfileData> {
|
||||
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<Dump>(
|
||||
`feed:profile-dumps:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const votesCache = useFeedCache<Dump>(
|
||||
`feed:profile-votes:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const playlistsCache = useFeedCache<Playlist>(
|
||||
`feed:profile-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
|
||||
const [state, setState] = useState<LoadState<ProfileData>>({
|
||||
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 = <T>(list: string, deserialize: (w: Wire<T>) => 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,
|
||||
};
|
||||
}
|
||||
81
src/hooks/shared/useFetchState.ts
Normal file
81
src/hooks/shared/useFetchState.ts
Normal file
@@ -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<T> = LoadState<{ data: T }>;
|
||||
|
||||
/** Returned by {@link useFetchState}. */
|
||||
export interface UseFetchStateResult<T> {
|
||||
state: FetchState<T>;
|
||||
/** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */
|
||||
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<T>(
|
||||
key: string | undefined,
|
||||
load: (signal: AbortSignal) => Promise<T>,
|
||||
enabled = true,
|
||||
): UseFetchStateResult<T> {
|
||||
const [state, setState] = useState<FetchState<T>>({ 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<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(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 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
<h2 className="related-section-title">
|
||||
<Trans>Related</Trans>
|
||||
</h2>
|
||||
<ul className="dump-feed">
|
||||
{relatedDumps.map((related) => (
|
||||
<DumpCard
|
||||
key={related.id}
|
||||
dump={related}
|
||||
voteCount={voteCounts[related.id] ?? related.voteCount}
|
||||
voted={myVotes.has(related.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<DumpCardList dumps={relatedDumps} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -581,11 +569,7 @@ export function Dump(): React.JSX.Element {
|
||||
<h2 className="related-section-title">
|
||||
<Trans>In collections</Trans>
|
||||
</h2>
|
||||
<ul className="dump-feed">
|
||||
{collections.map((collection) => (
|
||||
<PlaylistCard key={collection.id} playlist={collection} />
|
||||
))}
|
||||
</ul>
|
||||
<PlaylistCardList playlists={collections} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<DumpsState>(() =>
|
||||
const [dumpsState, setDumpsState] = useState<DumpFeedState>(() =>
|
||||
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 <NotFound />;
|
||||
@@ -344,14 +306,7 @@ export function Index(
|
||||
)}
|
||||
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
|
||||
{tab === "followed" && user && (
|
||||
<FollowedFeed
|
||||
categorySlug={categorySlug}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
<FollowedFeed categorySlug={categorySlug} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="load-more-btn"
|
||||
onClick={loadMore}
|
||||
disabled={state.loadingMore}
|
||||
>
|
||||
{state.loadingMore
|
||||
? <Trans>Loading…</Trans>
|
||||
: <Trans>Load more</Trans>}
|
||||
</button>
|
||||
<LoadMoreButton loadingMore={state.loadingMore} onClick={loadMore} />
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
@@ -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<Set<string>>(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<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => 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 (
|
||||
<div
|
||||
@@ -786,7 +731,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={cardCls}
|
||||
className={fadeClassName(phase)}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
/>
|
||||
{canEdit && (isActive
|
||||
@@ -980,22 +925,11 @@ function PlaylistEditForm(
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="visibility-toggle playlist-edit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={field.value ? "active" : ""}
|
||||
onClick={() => field.onChange(true)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!field.value ? "active" : ""}
|
||||
onClick={() => field.onChange(false)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<Tab>(SEARCH_TABS, "dumps");
|
||||
|
||||
const { token, user } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
const { token } = useAuth();
|
||||
|
||||
const [state, setState] = useState<SearchState>({ status: "idle" });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -246,32 +245,15 @@ export function Search(): React.JSX.Element {
|
||||
{state.dumps.items.length === 0
|
||||
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{state.dumps.items.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<DumpCardList dumps={state.dumps.items} />
|
||||
<FeedFooter
|
||||
hasMore={state.dumps.hasMore}
|
||||
loadingMore={state.dumps.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{state.dumps.loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -306,13 +288,7 @@ export function Search(): React.JSX.Element {
|
||||
{t`No playlists match "${q}".`}
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{state.playlists.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: <PlaylistCardList playlists={state.playlists} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="new-item-toggle"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
+<span className="btn-new-label">
|
||||
<Trans>New dump</Trans>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
actions={isOwnProfile && <NewDumpButton />}
|
||||
/>
|
||||
{createModalOpen && (
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
)}
|
||||
{dumps.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
@@ -111,31 +96,12 @@ export function UserDumps(): React.JSX.Element {
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={isOwnProfile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{hasMore && <div ref={sentinelRef} />}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
<DumpCardList dumps={dumps} />
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
@@ -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<Playlist>;
|
||||
followed: PagedList<Playlist>;
|
||||
};
|
||||
|
||||
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<Wire<Playlist>>
|
||||
: { items: [], hasMore: false };
|
||||
const followedData = followedBody.success
|
||||
? followedBody.data as PaginatedData<Wire<Playlist>>
|
||||
: { 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<string | null>(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 && (
|
||||
<NewPlaylistButton
|
||||
toggleClassName="btn-primary"
|
||||
onCreated={(p) =>
|
||||
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 {
|
||||
<ErrorCard title={t`Could not delete`} message={deleteError} />
|
||||
)}
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>
|
||||
Created ({created.items.length}
|
||||
{created.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<ProfileSection
|
||||
title={
|
||||
<Trans>
|
||||
Created ({created.items.length}
|
||||
{created.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{created.items.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
@@ -428,41 +345,30 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{created.items.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
playlist={p}
|
||||
isOwner={isOwnProfile}
|
||||
onDelete={isOwnProfile
|
||||
? () => setConfirmDeleteId(p.id)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<PlaylistCardList
|
||||
playlists={created.items}
|
||||
onDelete={isOwnProfile
|
||||
? (p) => setConfirmDeleteId(p.id)
|
||||
: undefined}
|
||||
/>
|
||||
<FeedFooter
|
||||
hasMore={created.hasMore}
|
||||
loadingMore={created.loadingMore}
|
||||
sentinelRef={createdSentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{created.hasMore && <div ref={createdSentinelRef} />}
|
||||
{created.loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!created.hasMore && created.items.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</ProfileSection>
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>
|
||||
Followed ({followed.items.length}
|
||||
{followed.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<ProfileSection
|
||||
title={
|
||||
<Trans>
|
||||
Followed ({followed.items.length}
|
||||
{followed.hasMore ? "+" : ""})
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{followed.items.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
@@ -470,24 +376,16 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{followed.items.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<PlaylistCardList playlists={followed.items} />
|
||||
<FeedFooter
|
||||
hasMore={followed.hasMore}
|
||||
loadingMore={followed.loadingMore}
|
||||
sentinelRef={followedSentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{followed.hasMore && <div ref={followedSentinelRef} />}
|
||||
{followed.loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!followed.hasMore && followed.items.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</ProfileSection>
|
||||
|
||||
{confirmDeleteId && (
|
||||
<ConfirmModal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Set<string>>(new Set());
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading();
|
||||
const prevMyVotesRef = useRef<Set<string> | 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 (
|
||||
<PageShell>
|
||||
@@ -185,7 +73,7 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
profileUser={profileUser}
|
||||
title={t`Upvoted`}
|
||||
/>
|
||||
{visibleDumps.length === 0
|
||||
{votes.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
@@ -193,40 +81,12 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const extraCls = phase === "cooldown"
|
||||
? "dump-card--fading"
|
||||
: phase === "dismissing"
|
||||
? "dump-card--dismissing"
|
||||
: undefined;
|
||||
return (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={extraCls}
|
||||
isOwner={!!me && me.id === dump.userId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hasMore && <div ref={sentinelRef} />}
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
)}
|
||||
<DumpCardList dumps={votes} cardClassName={cardClassName} />
|
||||
<FeedFooter
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
|
||||
82
src/test/hooks/shared/useFetchState.test.tsx
Normal file
82
src/test/hooks/shared/useFetchState.test.tsx
Normal file
@@ -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<number>();
|
||||
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<string>(() => {})
|
||||
: 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<number[]>();
|
||||
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] });
|
||||
});
|
||||
});
|
||||
53
src/test/utils/feed/pagedList.test.ts
Normal file
53
src/test/utils/feed/pagedList.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
64
src/utils/feed/pagedList.ts
Normal file
64
src/utils/feed/pagedList.ts
Normal file
@@ -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<T> {
|
||||
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<T>(items: T[], hasMore: boolean): PagedList<T> {
|
||||
return { items, hasMore, page: 1, loadingMore: false };
|
||||
}
|
||||
|
||||
/** Returns the list as `useFeedCache`'s `cached` restores it. */
|
||||
export function restoredList<T>(
|
||||
{ items, hasMore, page }: { items: T[]; hasMore: boolean; page: number },
|
||||
): PagedList<T> {
|
||||
return { items, hasMore, page, loadingMore: false };
|
||||
}
|
||||
|
||||
const EMPTY_PAGE: PaginatedData<never> = { items: [], hasMore: false };
|
||||
|
||||
/**
|
||||
* Returns the first page of the list at `url`, which takes `page` and `limit`
|
||||
* in its query, or an empty list when the server refuses it. The profile pages
|
||||
* load their lists with it beside the user, whose failure alone fails the
|
||||
* page.
|
||||
*
|
||||
* @throws {Error} Offline, when aborted, and when the body isn't an envelope.
|
||||
*/
|
||||
export async function fetchFirstPage<T>(
|
||||
url: string,
|
||||
deserialize: (wire: Wire<T>) => T,
|
||||
init?: RequestInit,
|
||||
): Promise<PagedList<T>> {
|
||||
const { items, hasMore } = await dataOr<PaginatedData<Wire<T>>>(
|
||||
await fetch(`${url}?page=1&limit=${DEFAULT_PAGE_SIZE}`, init),
|
||||
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<T extends { id: string }>(items: T[], item: T): T[] {
|
||||
return items.some((i) => i.id === item.id) ? items : [item, ...items];
|
||||
}
|
||||
@@ -46,3 +46,16 @@ export async function expectOk<T>(res: Response): Promise<T> {
|
||||
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<T>(res: Response, fallback: T): Promise<T> {
|
||||
const body = parseAPIResponse<T>(await res.json());
|
||||
return body.success ? body.data : fallback;
|
||||
}
|
||||
|
||||
28
src/utils/shared/fetchPublicUser.ts
Normal file
28
src/utils/shared/fetchPublicUser.ts
Normal file
@@ -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<PublicUser> {
|
||||
const res = await fetch(`${API_URL}/api/users/${username}`, { signal });
|
||||
if (res.status === 404) throw new Error("User not found");
|
||||
return deserializePublicUser(await expectOk<Wire<PublicUser>>(res));
|
||||
}
|
||||
16
src/utils/shared/loadState.ts
Normal file
16
src/utils/shared/loadState.ts
Normal file
@@ -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<L extends object> =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| ({ status: "loaded" } & L);
|
||||
Reference in New Issue
Block a user