v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
494
src/pages/UserPlaylists.tsx
Normal file
494
src/pages/UserPlaylists.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type {
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
RawPlaylist,
|
||||
} from "../model.ts";
|
||||
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||
|
||||
interface PlaylistFeed {
|
||||
items: Playlist[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
profileUser: PublicUser;
|
||||
created: PlaylistFeed;
|
||||
followed: PlaylistFeed;
|
||||
};
|
||||
|
||||
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
||||
return { items, hasMore, page: 1, loadingMore: false };
|
||||
}
|
||||
|
||||
export function UserPlaylists() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, token } = useAuth();
|
||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||
|
||||
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||
Playlist
|
||||
>(
|
||||
`feed:user-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
const { cached: cachedFollowed, saveState: saveFollowed } = useFeedCache<
|
||||
Playlist
|
||||
>(
|
||||
`feed:user-followed-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
if (cachedCreated && cachedFollowed) {
|
||||
fetch(`${API_URL}/api/users/${username}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("User not found");
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(body.data),
|
||||
created: {
|
||||
items: cachedCreated.items,
|
||||
hasMore: cachedCreated.hasMore,
|
||||
page: cachedCreated.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
followed: {
|
||||
items: cachedFollowed.items,
|
||||
hasMore: cachedFollowed.hasMore,
|
||||
page: cachedFollowed.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
),
|
||||
])
|
||||
.then(([userRes, createdRes, followedRes]) =>
|
||||
Promise.all([userRes.json(), createdRes.json(), followedRes.json()])
|
||||
)
|
||||
.then(([userBody, createdBody, followedBody]) => {
|
||||
if (!userBody.success) throw new Error("User not found");
|
||||
const createdData = createdBody.success
|
||||
? createdBody.data as PaginatedData<RawPlaylist>
|
||||
: { items: [], hasMore: false };
|
||||
const followedData = followedBody.success
|
||||
? followedBody.data as PaginatedData<RawPlaylist>
|
||||
: { items: [], hasMore: false };
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(userBody.data),
|
||||
created: initialFeed(
|
||||
createdData.items.map(deserializePlaylist),
|
||||
createdData.hasMore,
|
||||
),
|
||||
followed: initialFeed(
|
||||
followedData.items.map(deserializePlaylist),
|
||||
followedData.hasMore,
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}, [username]);
|
||||
|
||||
const loadMoreCreated = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.created.hasMore ||
|
||||
state.created.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.created.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${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,
|
||||
created: {
|
||||
items: [...s.created.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMoreFollowed = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.followed.hasMore ||
|
||||
state.followed.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.followed.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
followed: {
|
||||
items: [...s.followed.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username]);
|
||||
|
||||
const createdSentinelRef = useInfiniteScroll(
|
||||
loadMoreCreated,
|
||||
state.status === "loaded" && state.created.hasMore &&
|
||||
!state.created.loadingMore,
|
||||
);
|
||||
const followedSentinelRef = useInfiniteScroll(
|
||||
loadMoreFollowed,
|
||||
state.status === "loaded" && state.followed.hasMore &&
|
||||
!state.followed.loadingMore,
|
||||
);
|
||||
|
||||
// Real-time WS playlist updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
||||
const ev = lastPlaylistEvent;
|
||||
const isOwnProfile = me?.username === state.profileUser.username;
|
||||
|
||||
if (ev.type === "created" && ev.playlist?.userId === state.profileUser.id) {
|
||||
if (ev.playlist.isPublic || isOwnProfile) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.created.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: [ev.playlist!, ...s.created.items],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (ev.type === "updated") {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const updatedCreated = ev.playlist?.userId === state.profileUser.id
|
||||
? s.created.items
|
||||
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
|
||||
.filter((p) => p.isPublic || isOwnProfile)
|
||||
: s.created.items;
|
||||
const updatedFollowed = s.followed.items.map((p) =>
|
||||
p.id === ev.playlist?.id ? ev.playlist! : p
|
||||
).filter((p) => p.isPublic);
|
||||
return {
|
||||
...s,
|
||||
created: { ...s.created, items: updatedCreated },
|
||||
followed: { ...s.followed, items: updatedFollowed },
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: s.created.items.filter((p) => p.id !== ev.playlistId),
|
||||
},
|
||||
followed: {
|
||||
...s.followed,
|
||||
items: s.followed.items.filter((p) => p.id !== ev.playlistId),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [lastPlaylistEvent, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deletedPlaylistIds.size || state.status !== "loaded") return;
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: s.created.items.filter((p) => !deletedPlaylistIds.has(p.id)),
|
||||
},
|
||||
followed: {
|
||||
...s.followed,
|
||||
items: s.followed.items.filter((p) => !deletedPlaylistIds.has(p.id)),
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
// Scroll save
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
const y = globalThis.scrollY;
|
||||
saveCreated(
|
||||
state.created.items,
|
||||
state.created.page,
|
||||
state.created.hasMore,
|
||||
y,
|
||||
);
|
||||
saveFollowed(
|
||||
state.followed.items,
|
||||
state.followed.page,
|
||||
state.followed.hasMore,
|
||||
y,
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state, saveCreated, saveFollowed]);
|
||||
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cachedCreated?.scrollY == null || scrollRestored.current) return;
|
||||
if (state.status === "loaded") {
|
||||
globalThis.scrollTo(0, cachedCreated.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
}, [state.status, cachedCreated]);
|
||||
|
||||
const handleDelete = async (playlistId: string) => {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: s.created.items.filter((p) => p.id !== playlistId),
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<Link to={`/users/${username}`} className="logout-btn">
|
||||
← Back to profile
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, created, followed } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="profile-subpage-header">
|
||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||
← {profileUser.username}
|
||||
</Link>
|
||||
<div className="profile-subpage-title-row">
|
||||
<Avatar
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={36}
|
||||
/>
|
||||
<h1 className="profile-subpage-title">Playlists</h1>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
toggleClassName="btn-primary"
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.created.items.some((pl) => pl.id === p.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
created: { ...s.created, items: [p, ...s.created.items] },
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Created ({created.items.length}
|
||||
{created.hasMore ? "+" : ""})
|
||||
</h2>
|
||||
</div>
|
||||
{created.items.length === 0
|
||||
? <p className="empty-state">No playlists yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{created.items.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
playlist={p}
|
||||
isOwner={isOwnProfile}
|
||||
onDelete={isOwnProfile
|
||||
? () => setConfirmDeleteId(p.id)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={createdSentinelRef} />
|
||||
{created.loadingMore && (
|
||||
<p className="feed-loading-more">Loading more…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Followed ({followed.items.length}
|
||||
{followed.hasMore ? "+" : ""})
|
||||
</h2>
|
||||
</div>
|
||||
{followed.items.length === 0
|
||||
? <p className="empty-state">No followed playlists yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{followed.items.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={followedSentinelRef} />
|
||||
{followed.loadingMore && (
|
||||
<p className="feed-loading-more">Loading more…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{confirmDeleteId && (
|
||||
<ConfirmModal
|
||||
message="Delete this playlist? This cannot be undone."
|
||||
confirmLabel="Delete playlist"
|
||||
onConfirm={() => {
|
||||
handleDelete(confirmDeleteId);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user