v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
@@ -19,15 +19,66 @@ import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/FollowButton.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
function InviteButton() {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generate() {
try {
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
const body = await res.json();
if (body.success) {
const url =
`${globalThis.location.origin}/register?token=${body.data.token}`;
setInviteUrl(url);
} else {
setError("Failed to generate invite");
}
} catch {
setError("Failed to generate invite");
}
}
async function copy() {
if (!inviteUrl) return;
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (inviteUrl) {
return (
<div className="invite-result">
<span className="invite-url">{inviteUrl}</span>
<button type="button" className="invite-copy-btn" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
);
}
return (
<div className="invite-generate">
<button type="button" className="invite-btn" onClick={generate}>
+ Invite someone
</button>
{error && <p className="form-error">{error}</p>}
</div>
);
}
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
@@ -75,7 +126,9 @@ export function UserPublicProfile() {
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Playlist
>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
@@ -104,31 +157,64 @@ export function UserPublicProfile() {
setState({
status: "loaded",
user: deserializePublicUser(body.data),
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
dumps: {
items: cachedDumps.items,
hasMore: cachedDumps.hasMore,
page: cachedDumps.page,
loadingMore: false,
},
votes: {
items: cachedVotes.items,
hasMore: cachedVotes.hasMore,
page: cachedVotes.page,
loadingMore: false,
},
playlists: {
items: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
},
});
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
})
.catch((err) =>
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
setState({
status: "error",
error: err instanceof Error
? err.message
: "Failed to load profile",
})
);
return;
}
(async () => {
try {
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
]);
if (!userRes.ok) {
throw new Error(
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
userRes.status === 404
? "User not found"
: `HTTP ${userRes.status}`,
);
}
@@ -154,7 +240,10 @@ export function UserPublicProfile() {
setState({
status: "loaded",
user: deserializePublicUser(userBody.data),
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
dumps: initialList(
dumpsData.items.map(deserializeDump),
dumpsData.hasMore,
),
votes: initialList(voteItems, votesData.hasMore),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
@@ -189,7 +278,10 @@ export function UserPublicProfile() {
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
);
if (toAdd.length === 0) return s;
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
return {
...s,
votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
};
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]);
@@ -219,10 +311,16 @@ export function UserPublicProfile() {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
if (
s.status !== "loaded" ||
s.votes.items.some((d) => d.id === dumpId)
) {
return s;
}
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
return {
...s,
votes: { ...s.votes, items: [dump, ...s.votes.items] },
};
});
})
.catch(() => {});
@@ -243,7 +341,10 @@ export function UserPublicProfile() {
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
playlists: {
...s.playlists,
items: [ev.playlist!, ...s.playlists.items],
},
};
});
}
@@ -278,7 +379,9 @@ export function UserPublicProfile() {
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
setState((s) => {
if (s.status !== "loaded") return s;
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
const filtered = s.playlists.items.filter((p) =>
!deletedPlaylistIds.has(p.id)
);
if (filtered.length === s.playlists.items.length) return s;
return { ...s, playlists: { ...s.playlists, items: filtered } };
});
@@ -292,14 +395,22 @@ export function UserPublicProfile() {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = window.scrollY;
const y = globalThis.scrollY;
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
savePlaylists(
state.playlists.items,
state.playlists.page,
state.playlists.hasMore,
y,
);
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveDumps, saveVotes, savePlaylists]);
// Restore scroll position after cache restoration
@@ -307,94 +418,10 @@ export function UserPublicProfile() {
useLayoutEffect(() => {
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
window.scrollTo(0, cachedDumps.scrollY);
globalThis.scrollTo(0, cachedDumps.scrollY);
scrollRestored.current = true;
}
// cachedDumps is stable (read once), safe to omit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status]);
const loadMoreDumps = useCallback(() => {
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
const nextPage = state.dumps.page + 1;
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: {
items: [...s.dumps.items, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
}, [state, username, token]);
const loadMoreVotes = useCallback(() => {
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
const nextPage = state.votes.page + 1;
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
votes: {
items: [...s.votes.items, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
}, [state, username, token]);
const loadMorePlaylists = useCallback(() => {
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
const nextPage = state.playlists.page + 1;
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: {
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
}, [state, username, token]);
}, [state.status, cachedDumps]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -432,7 +459,10 @@ export function UserPublicProfile() {
setState((prev) =>
prev.status === "loaded"
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
? {
...prev,
user: { ...prev.user, avatarMime: body.data?.avatarMime },
}
: prev
);
} catch {
@@ -504,11 +534,37 @@ export function UserPublicProfile() {
</div>
<div>
<h1 className="profile-username">{profileUser.username}</h1>
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
invited by{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
>
@{profileUser.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{avatarError && <p className="form-error">{avatarError}</p>}
{!isOwnProfile && (
<FollowUserButton
targetUserId={profileUser.id}
targetUsername={profileUser.username}
/>
)}
{isOwnProfile && (
<button type="button" className="logout-btn" onClick={logout}>
Log out
</button>
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="logout-btn" onClick={logout}>
Log out
</button>
</div>
)}
</div>
</div>
@@ -523,9 +579,7 @@ export function UserPublicProfile() {
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
hasMore={dumps.hasMore}
loadingMore={dumps.loadingMore}
onLoadMore={loadMoreDumps}
viewAllHref={`/users/${profileUser.username}/dumps`}
/>
<UpvotedDumpList
@@ -537,16 +591,15 @@ export function UserPublicProfile() {
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
hasMore={votes.hasMore}
loadingMore={votes.loadingMore}
onLoadMore={loadMoreVotes}
viewAllHref={`/users/${profileUser.username}/upvoted`}
/>
</div>
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</h2>
{isOwnProfile && (
<NewPlaylistForm
@@ -556,7 +609,10 @@ export function UserPublicProfile() {
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
playlists: {
...s.playlists,
items: [p, ...s.playlists.items],
},
};
})}
/>
@@ -567,38 +623,23 @@ export function UserPublicProfile() {
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
))}
</ul>
)}
<PlaylistSentinel
hasMore={playlists.hasMore}
loadingMore={playlists.loadingMore}
onLoadMore={loadMorePlaylists}
/>
{playlists.items.length > 0 && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
View all
</Link>
)}
</section>
</PageShell>
);
}
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
function PlaylistSentinel(
{ hasMore, loadingMore, onLoadMore }: {
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── Plain dump list ──────────────────────────────────────────────────────────
function DumpList(
@@ -611,9 +652,7 @@ function DumpList(
castVote,
removeVote,
isOwnProfile,
hasMore,
loadingMore,
onLoadMore,
viewAllHref,
}: {
title: string;
dumps: Dump[];
@@ -623,13 +662,10 @@ function DumpList(
castVote: (id: string) => void;
removeVote: (id: string) => void;
isOwnProfile?: boolean;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
viewAllHref: string;
},
) {
const [createModalOpen, setCreateModalOpen] = useState(false);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<section className="profile-section">
<div className="profile-section-header">
@@ -665,8 +701,9 @@ function DumpList(
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{dumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">View all </Link>
)}
</section>
);
}
@@ -683,9 +720,7 @@ function UpvotedDumpList(
canVote,
castVote,
removeVote,
hasMore,
loadingMore,
onLoadMore,
viewAllHref,
}: {
title: string;
dumps: Dump[];
@@ -695,15 +730,14 @@ function UpvotedDumpList(
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
viewAllHref: string;
},
) {
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const prevVotedIds = useRef<Set<string> | null>(null);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
useEffect(() => () => {
cancels.current.forEach((c) => c());
@@ -809,8 +843,9 @@ function UpvotedDumpList(
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{visibleDumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">View all </Link>
)}
</section>
);
}