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>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user