v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||
import { dumpUrl } from "../utils/urls.ts";
|
||||
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
@@ -22,6 +23,7 @@ import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { CommentThread } from "../components/CommentThread.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
|
||||
type DumpState =
|
||||
@@ -115,6 +117,18 @@ export function Dump() {
|
||||
.catch(() => {});
|
||||
}, [selectedDump, token]);
|
||||
|
||||
// Scroll to and highlight a comment when navigating to #comment-{id}
|
||||
useEffect(() => {
|
||||
if (!location.hash.startsWith("#comment-")) return;
|
||||
const id = location.hash.slice(1);
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
el.classList.add("comment-node--highlight");
|
||||
const t = setTimeout(() => el.classList.remove("comment-node--highlight"), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [comments, location.hash]);
|
||||
|
||||
// React to WS comment events
|
||||
useEffect(() => {
|
||||
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
||||
@@ -135,6 +149,14 @@ export function Dump() {
|
||||
: c
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
lastCommentEvent.type === "updated" && lastCommentEvent.comment
|
||||
) {
|
||||
setComments((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === lastCommentEvent.comment!.id ? lastCommentEvent.comment! : c
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [lastCommentEvent, selectedDump]);
|
||||
|
||||
@@ -213,13 +235,21 @@ export function Dump() {
|
||||
</Link>
|
||||
)
|
||||
: <span className="dump-op-link">…</span>}
|
||||
<time
|
||||
className="dump-card-date"
|
||||
dateTime={dump.createdAt.toISOString()}
|
||||
title={dump.createdAt.toLocaleString()}
|
||||
>
|
||||
{relativeTime(dump.createdAt)}
|
||||
</time>
|
||||
<Tooltip text={dump.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="dump-card-date"
|
||||
dateTime={dump.createdAt.toISOString()}
|
||||
>
|
||||
{relativeTime(dump.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{dump.updatedAt && (
|
||||
<Tooltip text={`Edited ${dump.updatedAt.toLocaleString()}`}>
|
||||
<span className="dump-edited-label">
|
||||
edited {relativeTime(dump.updatedAt)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{dump.isPrivate && (
|
||||
<span className="dump-card-private-badge">private</span>
|
||||
)}
|
||||
@@ -251,7 +281,7 @@ export function Dump() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="dump-actions">
|
||||
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
||||
{canEdit && <Link to={`${dumpUrl(dump)}/edit`}>Edit</Link>}
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
</div>
|
||||
|
||||
@@ -271,6 +301,10 @@ export function Dump() {
|
||||
c.id === id ? { ...c, deleted: true, body: "" } : c
|
||||
)
|
||||
)}
|
||||
onCommentUpdated={(updated) =>
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === updated.id ? updated : c))
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{playlistModalOpen && (
|
||||
|
||||
@@ -6,10 +6,13 @@ import { API_URL } from "../config/api.ts";
|
||||
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { dumpUrl } from "../utils/urls.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import { MediaPlayer } from "../components/MediaPlayer.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
@@ -46,15 +49,8 @@ function LocalFilePreview({ file }: { file: File }) {
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return (
|
||||
<div className="local-preview-generic">
|
||||
<span className="local-preview-icon">
|
||||
{mime.startsWith("application/pdf") ? "📄" : "📎"}
|
||||
</span>
|
||||
<span className="local-preview-name">{file.name}</span>
|
||||
<span className="local-preview-size">{formatBytes(file.size)}</span>
|
||||
</div>
|
||||
);
|
||||
// For other types the drop zone chip already shows name + size.
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DumpCreate() {
|
||||
@@ -144,7 +140,7 @@ export function DumpCreate() {
|
||||
|
||||
const apiResponse = await res.json();
|
||||
if (apiResponse.success) {
|
||||
navigate(`/dumps/${apiResponse.data.id}`);
|
||||
navigate(dumpUrl(apiResponse.data));
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
@@ -266,26 +262,21 @@ export function DumpCreate() {
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div key="file-field" className="form-group">
|
||||
<label htmlFor="file">File</label>
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<FileDropZone
|
||||
file={file}
|
||||
onChange={setFile}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{file && <LocalFilePreview file={file} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment">Why are you dumping this?</label>
|
||||
<textarea
|
||||
<TextEditor
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
onChange={setComment}
|
||||
disabled={submitting}
|
||||
placeholder="Tell the community what makes this worth their time..."
|
||||
rows={3}
|
||||
|
||||
@@ -6,12 +6,15 @@ import type { Dump, UpdateDumpRequest } from "../model.ts";
|
||||
import { deserializeDump } from "../model.ts";
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { dumpUrl } from "../utils/urls.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||
|
||||
type DumpEditState =
|
||||
| { status: "loading" }
|
||||
@@ -101,7 +104,7 @@ export function DumpEdit() {
|
||||
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
||||
setState({ status: "loaded", dump: updatedDump });
|
||||
setNewFile(null);
|
||||
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
||||
navigate(dumpUrl(updatedDump), { state: { dump: updatedDump } });
|
||||
};
|
||||
|
||||
const handleRefreshMetadata = async () => {
|
||||
@@ -236,26 +239,22 @@ export function DumpEdit() {
|
||||
<strong>{dump.fileName}</strong>
|
||||
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||
</p>
|
||||
<label htmlFor="replace-file">Replace file</label>
|
||||
<input
|
||||
id="replace-file"
|
||||
type="file"
|
||||
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
|
||||
<FileDropZone
|
||||
file={newFile}
|
||||
onChange={setNewFile}
|
||||
label="Replace file"
|
||||
hint="Drop a replacement here"
|
||||
showLimit={false}
|
||||
/>
|
||||
{newFile && (
|
||||
<p className="file-input-info">
|
||||
{newFile.name} — {formatBytes(newFile.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment">Why are you dumping this?</label>
|
||||
<textarea
|
||||
<TextEditor
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.currentTarget.value)}
|
||||
onChange={setComment}
|
||||
placeholder="Tell the community what makes this worth their time..."
|
||||
rows={3}
|
||||
/>
|
||||
@@ -287,7 +286,7 @@ export function DumpEdit() {
|
||||
Delete dump
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<Link to={`/dumps/${dump.id}`} className="form-cancel">
|
||||
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="btn-primary">Save</button>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -172,6 +173,24 @@ export function Index() {
|
||||
DumpsState
|
||||
>({ status: "loading" });
|
||||
|
||||
const setFollowedUsersDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
useDumpListSync(setFollowedUsersDumpsItems);
|
||||
|
||||
const setFollowedPlaylistsDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
useDumpListSync(setFollowedPlaylistsDumpsItems);
|
||||
|
||||
const [tab, setTab] = useState<FeedTab>("hot");
|
||||
const [followedSection, setFollowedSection] = useState<FollowedSection>(
|
||||
"users",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import type {
|
||||
DumpUpvotedData,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
RawNotification,
|
||||
UserDumpPostedData,
|
||||
UserFollowedData,
|
||||
UserMentionedData,
|
||||
} from "../model.ts";
|
||||
import { deserializeNotification } from "../model.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
@@ -33,7 +35,7 @@ type State =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
|
||||
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist" | "mention";
|
||||
|
||||
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
switch (type) {
|
||||
@@ -47,16 +49,31 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
return "dump";
|
||||
case "playlist_dump_added":
|
||||
return "playlist";
|
||||
case "user_mentioned":
|
||||
return "mention";
|
||||
}
|
||||
}
|
||||
|
||||
const UpvoteSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
|
||||
<polygon points="5,1 9,9 1,9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FollowSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
||||
<polygon points="2,1 9,5 2,9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
const kind = notifIconKind(type);
|
||||
const glyphs: Record<NotifIconKind, string> = {
|
||||
upvote: "▲",
|
||||
follow: "►",
|
||||
const glyphs: Record<NotifIconKind, React.ReactNode> = {
|
||||
upvote: <UpvoteSvg />,
|
||||
follow: <FollowSvg />,
|
||||
dump: "🚚",
|
||||
playlist: "📜",
|
||||
mention: "@",
|
||||
};
|
||||
return (
|
||||
<span className={`notif-icon notif-icon--${kind}`}>
|
||||
@@ -65,78 +82,56 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function notificationLink(n: Notification): string {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
case "user_followed":
|
||||
return `/users/${(data as UserFollowedData).followerUsername}`;
|
||||
case "playlist_followed":
|
||||
return `/playlists/${(data as PlaylistFollowedData).playlistId}`;
|
||||
case "user_dump_posted":
|
||||
return `/dumps/${(data as UserDumpPostedData).dumpId}`;
|
||||
case "playlist_dump_added":
|
||||
return `/dumps/${(data as PlaylistDumpAddedData).dumpId}`;
|
||||
case "dump_upvoted":
|
||||
return `/dumps/${(data as DumpUpvotedData).dumpId}`;
|
||||
case "user_mentioned": {
|
||||
const d = data as UserMentionedData;
|
||||
if (d.contextType === "comment") return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||
return `/playlists/${d.contextId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notificationContent(n: Notification): React.ReactNode {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
case "user_followed": {
|
||||
const d = data as UserFollowedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
||||
{d.followerUsername}
|
||||
</Link>
|
||||
{" started following you"}
|
||||
</>
|
||||
);
|
||||
return <><strong>{d.followerUsername}</strong>{" started following you"}</>;
|
||||
}
|
||||
case "playlist_followed": {
|
||||
const d = data as PlaylistFollowedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
||||
{d.followerUsername}
|
||||
</Link>
|
||||
{" followed your playlist "}
|
||||
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
|
||||
{d.playlistTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
return <><strong>{d.followerUsername}</strong>{" followed your playlist "}<strong>{d.playlistTitle}</strong></>;
|
||||
}
|
||||
case "user_dump_posted": {
|
||||
const d = data as UserDumpPostedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.dumperUsername}`} className="notif-link">
|
||||
{d.dumperUsername}
|
||||
</Link>
|
||||
{" posted "}
|
||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||
{d.dumpTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
return <><strong>{d.dumperUsername}</strong>{" posted "}<strong>{d.dumpTitle}</strong></>;
|
||||
}
|
||||
case "playlist_dump_added": {
|
||||
const d = data as PlaylistDumpAddedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||
{d.dumpTitle}
|
||||
</Link>
|
||||
{" was added to "}
|
||||
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
|
||||
{d.playlistTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
return <><strong>{d.dumpTitle}</strong>{" was added to "}<strong>{d.playlistTitle}</strong></>;
|
||||
}
|
||||
case "dump_upvoted": {
|
||||
const d = data as DumpUpvotedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.voterUsername}`} className="notif-link">
|
||||
{d.voterUsername}
|
||||
</Link>
|
||||
{" upvoted "}
|
||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||
{d.dumpTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
return <><strong>{d.voterUsername}</strong>{" upvoted "}<strong>{d.dumpTitle}</strong></>;
|
||||
}
|
||||
case "user_mentioned": {
|
||||
const d = data as UserMentionedData;
|
||||
const where = d.contextTitle || (d.contextType === "comment" ? "a comment" : "a post");
|
||||
return <><strong>{d.mentionerUsername}</strong>{" mentioned you in "}<strong>{where}</strong></>;
|
||||
}
|
||||
default:
|
||||
return "New notification";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,21 +303,28 @@ export function Notifications() {
|
||||
!n.read ? " notification-item--unread" : ""
|
||||
}`}
|
||||
>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<span className="notif-dot" aria-hidden="true" />
|
||||
)}
|
||||
<Link
|
||||
to={notificationLink(n)}
|
||||
className="notification-item-link"
|
||||
>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<Tooltip text={n.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<span className="notif-dot" aria-hidden="true" />
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
|
||||
import { deserializePlaylistWithDumps } from "../model.ts";
|
||||
import type {
|
||||
PlaylistWithDumps,
|
||||
RawDump,
|
||||
RawPlaylist,
|
||||
RawPlaylistWithDumps,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylist,
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../model.ts";
|
||||
import { playlistUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
@@ -12,8 +22,10 @@ import { PageError } from "../components/PageError.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
|
||||
type LoadState =
|
||||
@@ -31,10 +43,13 @@ export function PlaylistDetail() {
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
// Stable UUID for WS comparisons — avoids re-running effects on every state change
|
||||
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||
|
||||
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
@@ -45,7 +60,9 @@ export function PlaylistDetail() {
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
const [dragSrcIndex, setDragSrcIndex] = useState<number | null>(null);
|
||||
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
|
||||
// without stale closure issues (state would only update on next render).
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -58,9 +75,18 @@ export function PlaylistDetail() {
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
|
||||
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
||||
// Updated on every render via useLayoutEffect so it's always current.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// Authoritative dump order from the server (fetchPlaylist + dumps_updated events).
|
||||
// Used to re-insert dumps at their correct position after private→public transitions.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeDumpIdsRef.current = activeDumpIds;
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
@@ -87,8 +113,10 @@ export function PlaylistDetail() {
|
||||
);
|
||||
setState({ status: "loaded", playlist: pl });
|
||||
const ids = new Set(pl.dumps.map((d) => d.id));
|
||||
const order = pl.dumps.map((d) => d.id);
|
||||
setActiveDumpIds(ids);
|
||||
prevActiveDumpIdsRef.current = ids;
|
||||
dumpOrderRef.current = order;
|
||||
for (const id of ids) knownDumpIdsRef.current.add(id);
|
||||
setFading({});
|
||||
cancels.current.forEach((c) => c());
|
||||
cancels.current.clear();
|
||||
@@ -172,13 +200,17 @@ export function PlaylistDetail() {
|
||||
|
||||
// WS: playlist metadata updated or deleted
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistId) return;
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
if (ev.playlistId !== playlistId) return;
|
||||
// Compare against the resolved UUID, not the URL param (which may be a slug)
|
||||
if (ev.playlistId !== playlistUUID) return;
|
||||
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const newIds = new Set(ev.dumpIds);
|
||||
const prev = prevActiveDumpIdsRef.current ?? new Set<string>();
|
||||
for (const id of newIds) knownDumpIdsRef.current.add(id);
|
||||
// Use the ref so we always diff against the current activeDumpIds,
|
||||
// including changes from deletedDumpIds / lastDumpEvent effects.
|
||||
const prev = activeDumpIdsRef.current;
|
||||
|
||||
// Removed: were active, not in new set → fade out
|
||||
for (const id of prev) {
|
||||
@@ -192,46 +224,65 @@ export function PlaylistDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-added while fading → cancel fade, restore to active
|
||||
// Newly added IDs: cancel any fade, mark active, fetch dump data individually.
|
||||
// We never call fetchPlaylist here — that would reset state to "loading", cycle
|
||||
// playlistUUID, and re-trigger this effect in a loop.
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
if (cancels.current.has(id)) {
|
||||
cancels.current.get(id)!();
|
||||
}
|
||||
// If this is a brand-new dump we haven't seen, re-fetch
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const known = s.playlist.dumps.some((d) => d.id === id);
|
||||
if (!known) {
|
||||
// Trigger a re-fetch asynchronously
|
||||
setTimeout(fetchPlaylist, 0);
|
||||
}
|
||||
return s;
|
||||
});
|
||||
cancels.current.get(id)?.();
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
// Capture ev.dumpIds so we can insert the new dump at its correct position.
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((body) => {
|
||||
if (!body?.success) return;
|
||||
const dump = deserializeDump(body.data as RawDump);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||
// Insert at the correct server-ordered position.
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
return {
|
||||
...s,
|
||||
playlist: {
|
||||
...s.playlist,
|
||||
dumps: [
|
||||
...orderedIds
|
||||
.filter((oid) => dumpMap.has(oid))
|
||||
.map((oid) => dumpMap.get(oid)!),
|
||||
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder active dumps to match the new server order,
|
||||
// keeping fading dumps at their current visual positions.
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
const activeQueue = ev.dumpIds!
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!);
|
||||
let qi = 0;
|
||||
const result = prev.playlist.dumps
|
||||
.filter((d) => dumpMap.has(d.id))
|
||||
.map((d) => newIds.has(d.id) ? activeQueue[qi++] : d);
|
||||
while (qi < activeQueue.length) result.push(activeQueue[qi++]);
|
||||
// Apply the server-authoritative order: active dumps in ev.dumpIds order,
|
||||
// fading dumps (not in newIds) appended at the end.
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: result },
|
||||
...s,
|
||||
playlist: {
|
||||
...s.playlist,
|
||||
dumps: [
|
||||
...ev.dumpIds!
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!),
|
||||
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
prevActiveDumpIdsRef.current = newIds;
|
||||
dumpOrderRef.current = ev.dumpIds!;
|
||||
} else if (ev.type === "updated" && ev.playlist) {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
@@ -249,7 +300,7 @@ export function PlaylistDetail() {
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
}
|
||||
}, [lastPlaylistEvent, playlistId]);
|
||||
}, [lastPlaylistEvent, playlistUUID]);
|
||||
|
||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||
useEffect(() => {
|
||||
@@ -269,36 +320,85 @@ export function PlaylistDetail() {
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
const handleDragStart = (index: number) => setDragSrcIndex(index);
|
||||
// Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public)
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
const dump = lastDumpEvent;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||
if (idx !== -1) {
|
||||
// Update in-place
|
||||
const dumps = [...prev.playlist.dumps];
|
||||
dumps[idx] = dump;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||
}
|
||||
// Re-add if this dump belongs to the playlist and is now public,
|
||||
// inserting at its correct server-ordered position.
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
const order = dumpOrderRef.current;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
const reinserted = order.length > 0
|
||||
? [
|
||||
...order.filter((id) => dumpMap.has(id)).map((id) => dumpMap.get(id)!),
|
||||
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
|
||||
]
|
||||
: [...prev.playlist.dumps, dump];
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
// Restore to activeDumpIds if re-added
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
setActiveDumpIds((prev) => {
|
||||
if (prev.has(dump.id)) return prev;
|
||||
return new Set([...prev, dump.id]);
|
||||
});
|
||||
}
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
dragSrcRef.current = index;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (dragSrcIndex === null || dragSrcIndex === index) return;
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// Only swap once the pointer has crossed the card's midpoint.
|
||||
// Without this, entering a card immediately re-triggers the swap in the
|
||||
// opposite direction (the two items keep bouncing back and forth).
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet
|
||||
if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet
|
||||
// Update visual order in state. Use activeDumpIdsRef so the updater never
|
||||
// reads a stale closure — activeDumpIds can't change mid-drag but this is
|
||||
// the correct pattern for setState updaters.
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
// Only reorder among active dumps
|
||||
const activeDumps = prev.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
const fadingDumps = prev.playlist.dumps.filter((d) =>
|
||||
!activeDumpIds.has(d.id)
|
||||
);
|
||||
const ids = activeDumpIdsRef.current;
|
||||
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
|
||||
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
|
||||
const reordered = [...activeDumps];
|
||||
const [moved] = reordered.splice(dragSrcIndex, 1);
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
setDragSrcIndex(index);
|
||||
setDragOverIndex(index);
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
// Update the ref and highlight index outside the updater (no side effects inside updaters).
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
if (state.status !== "loaded" || !playlistId) return;
|
||||
setDragSrcIndex(null);
|
||||
const src = dragSrcRef.current;
|
||||
dragSrcRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
if (src === null || state.status !== "loaded" || !playlistId) return;
|
||||
const activeDumps = state.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
@@ -341,13 +441,6 @@ export function PlaylistDetail() {
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [editDescription, editOpen]);
|
||||
|
||||
const openEdit = () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setEditTitle(state.playlist.title);
|
||||
@@ -364,15 +457,25 @@ export function PlaylistDetail() {
|
||||
setEditSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription || undefined,
|
||||
isPublic: editIsPublic,
|
||||
}),
|
||||
});
|
||||
const updateRes = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription || undefined,
|
||||
isPublic: editIsPublic,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const updateJson = await updateRes.json() as {
|
||||
success: boolean;
|
||||
data: RawPlaylist;
|
||||
};
|
||||
const updatedPlaylist = updateJson.success
|
||||
? deserializePlaylist(updateJson.data)
|
||||
: null;
|
||||
|
||||
if (imageFile) {
|
||||
const fd = new FormData();
|
||||
@@ -384,7 +487,11 @@ export function PlaylistDetail() {
|
||||
}
|
||||
|
||||
setEditOpen(false);
|
||||
fetchPlaylist();
|
||||
if (updatedPlaylist) {
|
||||
navigate(playlistUrl(updatedPlaylist), { replace: true });
|
||||
} else {
|
||||
fetchPlaylist();
|
||||
}
|
||||
} catch (err) {
|
||||
setEditError(friendlyFetchError(err));
|
||||
} finally {
|
||||
@@ -519,12 +626,12 @@ export function PlaylistDetail() {
|
||||
|
||||
{editOpen
|
||||
? (
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
onChange={setEditDescription}
|
||||
placeholder="Description (optional)"
|
||||
autoResize
|
||||
rows={1}
|
||||
/>
|
||||
)
|
||||
@@ -571,12 +678,18 @@ export function PlaylistDetail() {
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
<time
|
||||
dateTime={playlist.createdAt.toISOString()}
|
||||
title={playlist.createdAt.toLocaleString()}
|
||||
>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
</time>
|
||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||
<time dateTime={playlist.createdAt.toISOString()}>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{playlist.updatedAt && (
|
||||
<Tooltip text={`Edited ${playlist.updatedAt.toLocaleString()}`}>
|
||||
<span className="playlist-edited-label">
|
||||
edited {relativeTime(playlist.updatedAt)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -590,7 +703,10 @@ export function PlaylistDetail() {
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state">No dumps in this playlist yet.</p>
|
||||
: (
|
||||
<div className="playlist-dump-list">
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={isOwner ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{visibleDumps.map((dump) => {
|
||||
const isActive = activeDumpIds.has(dump.id);
|
||||
const phase = fading[dump.id];
|
||||
@@ -617,7 +733,7 @@ export function PlaylistDetail() {
|
||||
onDragOver={isOwner && isActive
|
||||
? (e) => handleDragOver(e, activeIndex)
|
||||
: undefined}
|
||||
onDragEnd={isOwner && isActive ? handleDragEnd : undefined}
|
||||
onDragEnd={isOwner ? handleDragEnd : undefined}
|
||||
>
|
||||
{isOwner && isActive && (
|
||||
<span className="drag-handle" aria-hidden>⠿</span>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -49,6 +50,19 @@ export function UserDumps() {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
|
||||
}, []);
|
||||
const addFilter = useCallback((dump: Dump): boolean => {
|
||||
if (!profileUserId) return false;
|
||||
if (dump.userId !== profileUserId) return false;
|
||||
return isOwnProfile || !dump.isPrivate;
|
||||
}, [profileUserId, isOwnProfile]);
|
||||
useDumpListSync(setDumps, addFilter);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
@@ -197,7 +211,6 @@ export function UserDumps() {
|
||||
}
|
||||
|
||||
const { profileUser, dumps, hasMore, loadingMore } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function UserLogin() {
|
||||
</form>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
No account? <Link to="/register">Register</Link>
|
||||
This is a mirage.
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
} from "../model.ts";
|
||||
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -55,7 +55,6 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
||||
export function UserPlaylists() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, token } = useAuth();
|
||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||
|
||||
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||
Playlist
|
||||
@@ -73,6 +72,28 @@ export function UserPlaylists() {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
||||
);
|
||||
}, []);
|
||||
usePlaylistListSync(setCreated, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: profileUserId ?? undefined,
|
||||
});
|
||||
|
||||
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
|
||||
);
|
||||
}, []);
|
||||
usePlaylistListSync(setFollowed, { noNewEntries: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
@@ -246,77 +267,6 @@ export function UserPlaylists() {
|
||||
!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;
|
||||
@@ -395,7 +345,6 @@ export function UserPlaylists() {
|
||||
}
|
||||
|
||||
const { profileUser, created, followed } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
@@ -19,6 +19,8 @@ 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 { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
@@ -114,10 +116,9 @@ export function UserPublicProfile() {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
lastVoteEvent,
|
||||
lastDumpEvent,
|
||||
castVote,
|
||||
removeVote,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
} = useWS();
|
||||
|
||||
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
|
||||
@@ -136,11 +137,104 @@ export function UserPublicProfile() {
|
||||
);
|
||||
|
||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const prev = s.dumps.items;
|
||||
const next = fn(prev);
|
||||
if (next.length < prev.length) {
|
||||
const nextIds = new Set(next.map((d) => d.id));
|
||||
prev.forEach((d, idx) => {
|
||||
if (!nextIds.has(d.id)) removedDumpPositionsRef.current.set(d.id, idx);
|
||||
});
|
||||
}
|
||||
return { ...s, dumps: { ...s.dumps, items: next } };
|
||||
});
|
||||
}, []);
|
||||
// No addFilter — insertion at correct position is handled by the effect below.
|
||||
useDumpListSync(setDumps);
|
||||
|
||||
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Tracks the list index of each dump at the moment it was removed from the
|
||||
// votes list, so we can re-insert it at the correct position when it becomes
|
||||
// public again (instead of always prepending at position 0).
|
||||
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
|
||||
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
||||
// a future dump_updated event (that would only be for private→public transitions).
|
||||
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const prev = s.votes.items;
|
||||
const next = fn(prev);
|
||||
if (next.length < prev.length) {
|
||||
const nextIds = new Set(next.map((d) => d.id));
|
||||
prev.forEach((d, idx) => {
|
||||
if (!nextIds.has(d.id)) removedVotePositionsRef.current.set(d.id, idx);
|
||||
});
|
||||
}
|
||||
return { ...s, votes: { ...s.votes, items: next } };
|
||||
});
|
||||
}, []);
|
||||
useDumpListSync(setVotes);
|
||||
|
||||
// Re-insert a vote-list dump at its original position after private→public.
|
||||
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally).
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
||||
const dump = lastDumpEvent;
|
||||
if (withdrawnVoteIdsRef.current.has(dump.id)) return;
|
||||
const savedIdx = removedVotePositionsRef.current.get(dump.id);
|
||||
if (savedIdx === undefined) return;
|
||||
removedVotePositionsRef.current.delete(dump.id);
|
||||
setVotes((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
||||
return next;
|
||||
});
|
||||
}, [lastDumpEvent, setVotes]);
|
||||
|
||||
// Re-insert a dumps-column dump at its original position after private→public.
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
||||
const dump = lastDumpEvent;
|
||||
if (dump.userId !== profileUserId) return;
|
||||
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
|
||||
if (savedIdx === undefined) return;
|
||||
removedDumpPositionsRef.current.delete(dump.id);
|
||||
setDumps((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
||||
return next;
|
||||
});
|
||||
}, [lastDumpEvent, profileUserId, setDumps]);
|
||||
|
||||
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
|
||||
);
|
||||
}, []);
|
||||
usePlaylistListSync(setPlaylists, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: profileUserId ?? undefined,
|
||||
});
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
@@ -260,8 +354,6 @@ export function UserPublicProfile() {
|
||||
})();
|
||||
}, [username]);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
|
||||
// Own profile: keep profileVotedIds in sync with myVotes
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
@@ -301,7 +393,10 @@ export function UserPublicProfile() {
|
||||
return n;
|
||||
});
|
||||
}
|
||||
withdrawnVoteIdsRef.current.add(dumpId);
|
||||
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
} else {
|
||||
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||
if (!isOwnProfile) {
|
||||
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
||||
}
|
||||
@@ -327,65 +422,6 @@ export function UserPublicProfile() {
|
||||
}
|
||||
}, [lastVoteEvent, me, profileUserId]);
|
||||
|
||||
// Real-time playlist updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
||||
const profileUserId = state.user.id;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
const ev = lastPlaylistEvent;
|
||||
|
||||
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
|
||||
if (ev.playlist.isPublic || isOwnProfile) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: [ev.playlist!, ...s.playlists.items],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: s.playlists.items
|
||||
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
|
||||
.filter((p) => p.isPublic || isOwnProfile),
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [lastPlaylistEvent, me]);
|
||||
|
||||
useEffect(() => {
|
||||
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)
|
||||
);
|
||||
if (filtered.length === s.playlists.items.length) return s;
|
||||
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
||||
});
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
// Save scroll position + loaded state to sessionStorage on scroll
|
||||
useEffect(() => {
|
||||
@@ -506,7 +542,6 @@ export function UserPublicProfile() {
|
||||
}
|
||||
|
||||
const { user: profileUser, dumps, votes, playlists } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -46,6 +47,12 @@ export function UserUpvoted() {
|
||||
);
|
||||
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
|
||||
const setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
|
||||
}, []);
|
||||
useDumpListSync(setVotesDumps);
|
||||
|
||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
|
||||
Reference in New Issue
Block a user