670 lines
23 KiB
TypeScript
670 lines
23 KiB
TypeScript
/**
|
|
* Renders a dump's page, with its preview, comments, related dumps and the
|
|
* playlists holding it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import {
|
|
dumpThumbnailUrl,
|
|
dumpUrl,
|
|
isAt,
|
|
userApiUrl,
|
|
} from "../utils/shared/urls.ts";
|
|
import { can } from "../utils/auth/permissions.ts";
|
|
import { CategoryChips } from "../components/category/CategoryChips.tsx";
|
|
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
|
|
|
|
import { API_URL } from "../config/api.ts";
|
|
import { VALIDATION } from "../../shared/validation.ts";
|
|
|
|
import type { Comment } from "../../shared/comment.ts";
|
|
import type { Dump, UpdateDumpRequest } from "../../shared/dump.ts";
|
|
import type { Playlist } from "../../shared/playlist.ts";
|
|
import type { PublicUser } from "../../shared/user.ts";
|
|
import type { Wire } from "../../shared/api.ts";
|
|
import {
|
|
deserializeComment,
|
|
deserializeDump,
|
|
deserializePlaylist,
|
|
deserializePublicUser,
|
|
} from "../utils/shared/deserialize.ts";
|
|
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
|
|
|
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
|
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
|
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
|
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
|
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
|
import { Avatar } from "../components/shared/Avatar.tsx";
|
|
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
|
|
import { FilePreview } from "../components/dump/FilePreview.tsx";
|
|
import { VoteButton } from "../components/social/VoteButton.tsx";
|
|
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
|
|
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
|
import { PageShell } from "../components/layout/PageShell.tsx";
|
|
import { PageError } from "../components/layout/PageError.tsx";
|
|
import { Markdown } from "../components/editor/Markdown.tsx";
|
|
import { CommentThread } from "../components/comment/CommentThread.tsx";
|
|
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
|
import { userPath } from "../../shared/paths.ts";
|
|
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
|
|
|
|
type DumpState =
|
|
| { status: "loading" }
|
|
| { status: "error"; error: string }
|
|
| { status: "loaded"; dump: Dump };
|
|
|
|
/**
|
|
* Renders `/@:username/dumps/:slug`, for anyone who may see the dump: a
|
|
* private dump answers 404 to anyone but its poster. The dump comes from
|
|
* `GET /api/users/@:username/dumps/:slug`, or from the navigation's state when
|
|
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
|
* slug, the URL takes the current ones. Its comments, related dumps and
|
|
* playlists come from their own endpoints once it is loaded, and the socket's
|
|
* events keep them current; a reconnection fetches the dump and its comments
|
|
* again. A dump deleted, or made private by its poster, while someone else
|
|
* views it gives way to a notice. The poster and moderators can edit the
|
|
* title in place, and get a link to `DumpEdit`.
|
|
*/
|
|
export function Dump(): React.JSX.Element {
|
|
const { handle, slug } = useParams();
|
|
const username = handle?.slice(1);
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
// Taken once, since it stays in the history entry, where a reload or Back
|
|
// would show that snapshot forever. It is cleared from there below.
|
|
const [handedOver, setHandedOver] = useState(
|
|
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
|
|
);
|
|
const preloaded = isAt(handedOver, username, slug) ? handedOver : null;
|
|
useEffect(() => {
|
|
if (!(location.state as { dump?: Dump } | null)?.dump) return;
|
|
navigate(`${location.pathname}${location.search}${location.hash}`, {
|
|
replace: true,
|
|
state: null,
|
|
});
|
|
}, [location, navigate]);
|
|
|
|
const [dumpState, setDumpState] = useState<DumpState>(
|
|
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
|
);
|
|
const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null;
|
|
// The path last rendered. One that names another dump, as a related dump's
|
|
// link does, drops the dump on screen in the same render: left for the
|
|
// fetch below to drop, useCanonicalPath would first put its path back in
|
|
// the URL. The dump handed over goes with it, so that coming back to its
|
|
// path fetches it.
|
|
const [target, setTarget] = useState({ username, slug });
|
|
if (target.username !== username || target.slug !== slug) {
|
|
setTarget({ username, slug });
|
|
if (!isAt(loadedDump, username, slug)) {
|
|
setHandedOver(null);
|
|
setDumpState({ status: "loading" });
|
|
}
|
|
}
|
|
useCanonicalPath(loadedDump && dumpUrl(loadedDump));
|
|
// The dump on screen, which a path it was just given, by useCanonicalPath or
|
|
// a slug edited elsewhere, needn't fetch again.
|
|
const shownRef = useRef<Dump | null>(loadedDump);
|
|
useEffect(() => {
|
|
shownRef.current = loadedDump;
|
|
}, [loadedDump]);
|
|
const [op, setOp] = useState<PublicUser | null>(null);
|
|
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
|
|
|
useDocumentTitle(
|
|
dumpState.status === "loaded"
|
|
? dumpState.dump.title
|
|
: dumpState.status === "error"
|
|
? null
|
|
: undefined,
|
|
);
|
|
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
|
const [collections, setCollections] = useState<Playlist[]>([]);
|
|
|
|
const [titleEditing, setTitleEditing] = useState(false);
|
|
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
|
|
const [titleDraft, setTitleDraft] = useState("");
|
|
const [titleSaving, setTitleSaving] = useState(false);
|
|
const [titleError, setTitleError] = useState<string | null>(null);
|
|
|
|
const { user, mediaToken, authFetch } = useAuth();
|
|
const {
|
|
voteCounts,
|
|
myVotes,
|
|
castVote,
|
|
removeVote,
|
|
deletedDumpIds,
|
|
connectionEpoch,
|
|
} = useWS();
|
|
|
|
useEffect(() => {
|
|
if (!username || !slug) return;
|
|
const controller = new AbortController();
|
|
|
|
if (preloaded) {
|
|
fetch(userApiUrl({ id: preloaded.userId }), {
|
|
signal: controller.signal,
|
|
})
|
|
.then((r) => r.json())
|
|
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
|
.catch(() => {});
|
|
return () => controller.abort();
|
|
}
|
|
|
|
if (isAt(shownRef.current, username, slug)) return;
|
|
|
|
(async () => {
|
|
setDumpState({ status: "loading" });
|
|
setOp(null);
|
|
try {
|
|
const res = await authFetch(
|
|
userApiUrl({ username }, "dumps", slug),
|
|
{ cache: "no-store", signal: controller.signal },
|
|
);
|
|
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
|
|
if (!apiResponse.success) {
|
|
throw new Error(apiResponse.error.message);
|
|
}
|
|
const dump: Dump = deserializeDump(apiResponse.data);
|
|
setDumpState({ status: "loaded", dump });
|
|
|
|
fetch(userApiUrl({ id: dump.userId }), {
|
|
signal: controller.signal,
|
|
})
|
|
.then((r) => r.json())
|
|
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
|
.catch(() => {});
|
|
} catch (err) {
|
|
if ((err as Error).name === "AbortError") return;
|
|
setDumpState({ status: "error", error: friendlyFetchError(err) });
|
|
}
|
|
})();
|
|
return () => controller.abort();
|
|
}, [username, slug, preloaded, authFetch]);
|
|
|
|
const replaceDump = (dump: Dump) =>
|
|
setDumpState((prev) =>
|
|
prev.status === "loaded" && prev.dump.id === dump.id
|
|
? { status: "loaded", dump }
|
|
: prev
|
|
);
|
|
useWSEvent("dump", ({ dump }) => replaceDump(dump));
|
|
|
|
const loadedDumpId = loadedDump?.id ?? null;
|
|
|
|
// The dump found gone on a reconnection. One deleted, or made private, live
|
|
// is in `deletedDumpIds`, which it leaves if made public again, and shows
|
|
// again.
|
|
const [vanishedId, setVanishedId] = useState<string | null>(null);
|
|
const vanished = !!loadedDumpId &&
|
|
(deletedDumpIds.has(loadedDumpId) || vanishedId === loadedDumpId);
|
|
|
|
// An edit or a deletion missed while the socket was down.
|
|
useOnReconnect(() => {
|
|
if (!loadedDumpId) return;
|
|
authFetch(`${API_URL}/api/dumps/${loadedDumpId}`, { cache: "no-store" })
|
|
.then(async (res) => {
|
|
if (res.status === 404) return setVanishedId(loadedDumpId);
|
|
const body = parseAPIResponse<Wire<Dump>>(await res.json());
|
|
if (body.success) {
|
|
setVanishedId(null);
|
|
replaceDump(deserializeDump(body.data));
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
|
|
useWSEvent("user", ({ user }) => {
|
|
setOp((op) => op?.id === user.id ? user : op);
|
|
setComments((prev) =>
|
|
prev.some((c) =>
|
|
c.userId === user.id && c.authorUsername !== user.username
|
|
)
|
|
? prev.map((c) =>
|
|
c.userId === user.id ? { ...c, authorUsername: user.username } : c
|
|
)
|
|
: prev
|
|
);
|
|
setDumpState((prev) =>
|
|
prev.status === "loaded" && prev.dump.userId === user.id &&
|
|
prev.dump.ownerUsername !== user.username
|
|
? {
|
|
status: "loaded",
|
|
dump: { ...prev.dump, ownerUsername: user.username },
|
|
}
|
|
: prev
|
|
);
|
|
});
|
|
|
|
useDumpListSync(relatedDumps, setRelatedDumps);
|
|
|
|
// Counts the playlist events, each of which may change the playlists
|
|
// holding the dump.
|
|
const [playlistsVersion, setPlaylistsVersion] = useState(0);
|
|
useWSEvent("playlist", () => setPlaylistsVersion((n) => n + 1));
|
|
|
|
useEffect(() => {
|
|
if (!loadedDumpId) return;
|
|
const controller = new AbortController();
|
|
authFetch(`${API_URL}/api/dumps/${loadedDumpId}/comments`, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
if (body.success) {
|
|
setComments((body.data as Wire<Comment>[]).map(deserializeComment));
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
return () => controller.abort();
|
|
// connectionEpoch refetches on reconnect, for the comments missed while the
|
|
// socket was down.
|
|
}, [loadedDumpId, authFetch, connectionEpoch]);
|
|
|
|
// The dumps that link to this one, or that it links to.
|
|
useEffect(() => {
|
|
if (!loadedDumpId) return;
|
|
const controller = new AbortController();
|
|
authFetch(`${API_URL}/api/dumps/${loadedDumpId}/related`, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
if (body.success) {
|
|
setRelatedDumps((body.data as Wire<Dump>[]).map(deserializeDump));
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
return () => controller.abort();
|
|
}, [loadedDumpId, authFetch]);
|
|
|
|
// The server leaves out the private playlists of others.
|
|
useEffect(() => {
|
|
if (!loadedDumpId) return;
|
|
const controller = new AbortController();
|
|
authFetch(`${API_URL}/api/dumps/${loadedDumpId}/playlists`, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
if (body.success) {
|
|
setCollections(
|
|
(body.data as Wire<Playlist>[]).map(deserializePlaylist),
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
return () => controller.abort();
|
|
// Any playlist event may change the list, and they are rare enough to
|
|
// refetch on each, as on a reconnection.
|
|
}, [loadedDumpId, authFetch, playlistsVersion, connectionEpoch]);
|
|
|
|
// A notification links to `#comment-<id>`. Rerun when the comments arrive,
|
|
// since the element doesn't exist before.
|
|
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 timer = setTimeout(
|
|
() => el.classList.remove("comment-node--highlight"),
|
|
2000,
|
|
);
|
|
return () => clearTimeout(timer);
|
|
}, [comments, location.hash]);
|
|
|
|
useWSEvent("comment", (ev) => {
|
|
if (!loadedDumpId || ev.dumpId !== loadedDumpId) return;
|
|
// A comment of this user's arrives both from the socket and from
|
|
// CommentThread's callbacks below, hence the duplicate checks.
|
|
const { comment, commentId } = ev;
|
|
if (ev.type === "created" && comment) {
|
|
setComments((prev) =>
|
|
prev.some((c) => c.id === comment.id) ? prev : [...prev, comment]
|
|
);
|
|
} else if (ev.type === "deleted" && commentId) {
|
|
setComments((prev) =>
|
|
prev.map((c) =>
|
|
c.id === commentId ? { ...c, deleted: true, body: "" } : c
|
|
)
|
|
);
|
|
} else if (ev.type === "updated" && comment) {
|
|
setComments((prev) => prev.map((c) => c.id === comment.id ? comment : c));
|
|
}
|
|
});
|
|
|
|
if (dumpState.status === "loading") {
|
|
return (
|
|
<PageShell>
|
|
<p className="page-loading">
|
|
<Trans>Loading dump…</Trans>
|
|
</p>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
if (vanished) {
|
|
return (
|
|
<PageError
|
|
message={t`This dump was deleted, or made private.`}
|
|
actions={
|
|
<button
|
|
className="btn-border"
|
|
type="button"
|
|
onClick={() => navigate("/")}
|
|
>
|
|
<Trans>← Back to all dumps</Trans>
|
|
</button>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (dumpState.status === "error") {
|
|
return (
|
|
<PageError
|
|
message={dumpState.error}
|
|
actions={
|
|
<>
|
|
<button
|
|
className="btn-border"
|
|
type="button"
|
|
onClick={() => globalThis.location.reload()}
|
|
>
|
|
<Trans>Retry</Trans>
|
|
</button>
|
|
<button
|
|
className="btn-border"
|
|
type="button"
|
|
onClick={() => navigate("/")}
|
|
>
|
|
<Trans>← Back to all dumps</Trans>
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const { dump } = dumpState;
|
|
const canEdit = !!user &&
|
|
(dump.userId === user.id || can(user, "dump:moderate"));
|
|
|
|
const startTitleEdit = () => {
|
|
setTitleDraft(dump.title);
|
|
setTitleError(null);
|
|
setTitleEditing(true);
|
|
};
|
|
|
|
const handleTitleSave = async () => {
|
|
const trimmed = titleDraft.trim();
|
|
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
|
|
|
|
setTitleSaving(true);
|
|
setTitleError(null);
|
|
try {
|
|
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(
|
|
{ title: trimmed } satisfies UpdateDumpRequest,
|
|
),
|
|
});
|
|
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
|
|
if (!apiResponse.success) {
|
|
setTitleError(apiResponse.error.message);
|
|
return;
|
|
}
|
|
const updatedDump = deserializeDump(apiResponse.data);
|
|
setTitleEditing(false);
|
|
// The page stays mounted across the slug change below, and would keep
|
|
// the old title.
|
|
setDumpState({ status: "loaded", dump: updatedDump });
|
|
if (updatedDump.slug !== dump.slug) {
|
|
navigate(dumpUrl(updatedDump), {
|
|
replace: true,
|
|
state: { dump: updatedDump },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
setTitleError(friendlyFetchError(err));
|
|
} finally {
|
|
setTitleSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageShell>
|
|
<div className="dump-detail">
|
|
<div className="dump-post-header">
|
|
<div className="dump-header-block">
|
|
<div className="dump-header-left">
|
|
<VoteButton
|
|
dumpId={dump.id}
|
|
count={voteCounts[dump.id] ?? dump.voteCount}
|
|
voted={myVotes.has(dump.id)}
|
|
disabled={!user}
|
|
onCast={castVote}
|
|
onRemove={removeVote}
|
|
/>
|
|
{user && (
|
|
<button
|
|
type="button"
|
|
className="btn-add-playlist"
|
|
onClick={() => setPlaylistModalOpen(true)}
|
|
>
|
|
<Trans>+ Playlist</Trans>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="dump-header-right" ref={headerRightRef}>
|
|
{titleEditing
|
|
? (
|
|
<div className="dump-title-editor animate-in">
|
|
<input
|
|
type="text"
|
|
className="dump-title-input"
|
|
value={titleDraft}
|
|
onChange={(e) => setTitleDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleTitleSave();
|
|
} else if (e.key === "Escape") {
|
|
setTitleEditing(false);
|
|
}
|
|
}}
|
|
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
|
disabled={titleSaving}
|
|
autoFocus
|
|
/>
|
|
<div className="dump-title-actions">
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
onClick={handleTitleSave}
|
|
disabled={titleSaving || !titleDraft.trim()}
|
|
>
|
|
{titleSaving
|
|
? <Trans>Saving…</Trans>
|
|
: <Trans>Save</Trans>}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
onClick={() => setTitleEditing(false)}
|
|
disabled={titleSaving}
|
|
>
|
|
<Trans>Cancel</Trans>
|
|
</button>
|
|
</div>
|
|
{titleError && (
|
|
<p className="dump-title-error">{titleError}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
: (
|
|
<div
|
|
className={`dump-title-view${
|
|
canEdit ? " dump-title-view--editable" : ""
|
|
}`}
|
|
onClick={canEdit ? startTitleEdit : undefined}
|
|
// Reachable from the keyboard too; not a button, so the
|
|
// title inside stays a heading.
|
|
tabIndex={canEdit ? 0 : undefined}
|
|
title={canEdit ? t`Edit title` : undefined}
|
|
onKeyDown={canEdit
|
|
? (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
startTitleEdit();
|
|
}
|
|
}
|
|
: undefined}
|
|
>
|
|
<h1 className="dump-title">{dump.title}</h1>
|
|
{canEdit && (
|
|
<span className="dump-title-edit-btn" aria-hidden>
|
|
✎
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="dump-op">
|
|
<Avatar
|
|
userId={dump.userId}
|
|
username={op?.username ?? "?"}
|
|
hasAvatar={!!op?.avatarMime}
|
|
size={22}
|
|
/>
|
|
{op
|
|
? (
|
|
<Link to={userPath(op.username)} className="dump-op-link">
|
|
{op.username}
|
|
</Link>
|
|
)
|
|
: <span className="dump-op-link">…</span>}
|
|
<Tooltip text={dump.createdAt.toLocaleString()}>
|
|
<time
|
|
className="dump-card-date"
|
|
dateTime={dump.createdAt.toISOString()}
|
|
>
|
|
{relativeTime(dump.createdAt)}
|
|
</time>
|
|
</Tooltip>
|
|
{dump.updatedAt && (
|
|
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
|
|
<span className="dump-edited-label">
|
|
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
{dump.isPrivate && (
|
|
<span className="dump-card-private-badge">
|
|
<Trans>private</Trans>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<CategoryChips kind="dump" ids={dump.categoryIds} />
|
|
</div>
|
|
</div>
|
|
|
|
{dump.comment && (
|
|
<Markdown className="dump-comment">{dump.comment}</Markdown>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dump-rich-content">
|
|
{dump.kind === "file"
|
|
? <FilePreview dump={dump} global />
|
|
: dump.richContent
|
|
? (
|
|
<RichContentCard
|
|
richContent={dump.richContent}
|
|
dumpHref={dumpUrl(dump)}
|
|
thumbnailOverrideUrl={dump.thumbnailMime
|
|
? dumpThumbnailUrl(dump, mediaToken)
|
|
: undefined}
|
|
/>
|
|
)
|
|
: (
|
|
<a
|
|
href={dump.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="dump-url-link"
|
|
>
|
|
{dump.url}
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dump-actions">
|
|
{canEdit && (
|
|
<Link to={dumpUrl(dump, "edit")}>
|
|
<Trans>Edit</Trans>
|
|
</Link>
|
|
)}
|
|
<Link to="/">
|
|
<Trans>← Back to all dumps</Trans>
|
|
</Link>
|
|
</div>
|
|
|
|
{relatedDumps.length > 0 && (
|
|
<section className="related-section">
|
|
<h2 className="related-section-title">
|
|
<Trans>Related</Trans>
|
|
</h2>
|
|
<DumpCardList dumps={relatedDumps} />
|
|
</section>
|
|
)}
|
|
|
|
{collections.length > 0 && (
|
|
<section className="related-section collections-section">
|
|
<h2 className="related-section-title">
|
|
<Trans>In collections</Trans>
|
|
</h2>
|
|
<PlaylistCardList playlists={collections} privateBadgeOnly />
|
|
</section>
|
|
)}
|
|
|
|
<CommentThread
|
|
dumpId={dump.id}
|
|
comments={comments}
|
|
currentUser={user}
|
|
onCommentCreated={(c) =>
|
|
setComments((prev) =>
|
|
prev.some((x) => x.id === c.id) ? prev : [...prev, c]
|
|
)}
|
|
onCommentDeleted={(id) =>
|
|
setComments((prev) =>
|
|
prev.map((c) =>
|
|
c.id === id ? { ...c, deleted: true, body: "" } : c
|
|
)
|
|
)}
|
|
onCommentUpdated={(updated) =>
|
|
setComments((prev) =>
|
|
prev.map((c) => (c.id === updated.id ? updated : c))
|
|
)}
|
|
/>
|
|
</div>
|
|
{playlistModalOpen && (
|
|
<AddToPlaylistModal
|
|
dumpId={dump.id}
|
|
onClose={() => setPlaylistModalOpen(false)}
|
|
/>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|