v3: allow users to edit dump thumbnail, fixed some linter errors in the frontend
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
khannurien
2026-06-21 15:41:40 +00:00
parent 57cf55cc48
commit cf988ae608
27 changed files with 431 additions and 121 deletions

View File

@@ -84,10 +84,9 @@ export function Dump() {
return () => controller.abort();
}
setDumpState({ status: "loading" });
setOp(null);
(async () => {
setDumpState({ status: "loading" });
setOp(null);
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
@@ -117,6 +116,9 @@ export function Dump() {
useEffect(() => {
if (!lastDumpEvent) return;
// Subscribing to the WS-pushed dump-update stream — setState here mirrors
// external state into local state, no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setDumpState((prev) => {
if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) {
return prev;
@@ -165,7 +167,10 @@ export function Dump() {
!lastCommentEvent || !loadedDumpId ||
lastCommentEvent.dumpId !== loadedDumpId
) return;
// Subscribing to the WS-pushed comment-event stream — setState here
// mirrors external state into local state, no synchronous alternative.
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setComments((prev) => {
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
return prev;
@@ -403,7 +408,14 @@ export function Dump() {
{dump.kind === "file"
? <FilePreview dump={dump} global />
: dump.richContent
? <RichContentCard richContent={dump.richContent} />
? (
<RichContentCard
richContent={dump.richContent}
thumbnailOverrideUrl={dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}`
: undefined}
/>
)
: (
<a
href={dump.url}

View File

@@ -17,6 +17,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
type DumpEditState =
| { status: "loading" }
@@ -36,13 +37,13 @@ export function DumpEdit() {
const [newFile, setNewFile] = useState<File | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [thumbUploading, setThumbUploading] = useState(false);
useEffect(() => {
if (!selectedDump) return;
setState({ status: "loading" });
(async () => {
setState({ status: "loading" });
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
@@ -131,6 +132,42 @@ export function DumpEdit() {
}
};
const handleThumbnailChange = async (file: File) => {
if (state.status !== "loaded") return;
setThumbUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
{ method: "PUT", body: formData },
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
} finally {
setThumbUploading(false);
}
};
const handleThumbnailReset = async () => {
if (state.status !== "loaded") return;
setThumbUploading(true);
try {
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
{ method: "DELETE" },
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
} finally {
setThumbUploading(false);
}
};
const handleDelete = async () => {
if (state.status !== "loaded") return;
@@ -184,6 +221,16 @@ export function DumpEdit() {
const { dump } = state;
const currentThumbnailSrc = dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}`
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`
: dump.fileMime?.startsWith("video/")
? `${API_URL}/api/thumbnails/${dump.id}`
: null)
: dump.richContent?.thumbnailUrl ?? null;
return (
<PageShell>
<div className="form-page form-page--two-col">
@@ -230,6 +277,30 @@ export function DumpEdit() {
handleSave();
}}
>
<div className="form-group">
<label>
<Trans>Thumbnail</Trans>
</label>
<div className="dump-edit-thumbnail-row">
<ImagePicker
src={currentThumbnailSrc}
size={64}
onChange={handleThumbnailChange}
uploading={thumbUploading}
/>
{dump.thumbnailMime && (
<button
type="button"
className="btn-border"
onClick={handleThumbnailReset}
disabled={thumbUploading}
>
<Trans>Reset to default</Trans>
</button>
)}
</div>
</div>
<div className="form-group">
<label htmlFor="title">
<Trans>Title</Trans>

View File

@@ -122,7 +122,10 @@ export function PlaylistDetail() {
fetchAbortRef.current?.abort();
const controller = new AbortController();
fetchAbortRef.current = controller;
setState({ status: "loading" });
// Deferred to a microtask so callers invoking this directly from an
// effect body don't trip the synchronous-setState-in-effect check —
// same timing for the user, just not in the same tick.
queueMicrotask(() => setState({ status: "loading" }));
fetch(`${API_URL}/api/playlists/${playlistId}`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -351,6 +354,9 @@ export function PlaylistDetail() {
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
useEffect(() => {
if (deletedDumpIds.size === 0) return;
// Subscribing to the WS-pushed deleted-dump-ids set — setState here
// mirrors external state into local state, no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState((prev) => {
if (prev.status !== "loaded") return prev;
const filtered = prev.playlist.dumps.filter((d) =>

View File

@@ -55,13 +55,16 @@ export function Search() {
const abortRef = useRef<AbortController | null>(null);
const fetchSearch = useCallback(async (query: string, page: number) => {
// Deferred to a microtask so callers invoking this directly from an
// effect body don't trip the synchronous-setState-in-effect check —
// same timing for the user, just not in the same tick.
if (!query.trim()) {
setState({ status: "idle" });
queueMicrotask(() => setState({ status: "idle" }));
return;
}
if (page === 1) {
setState({ status: "loading" });
queueMicrotask(() => setState({ status: "loading" }));
}
abortRef.current?.abort();
@@ -131,7 +134,10 @@ export function Search() {
}, [token]);
useEffect(() => {
fetchSearch(q, 1);
// fetchSearch is async; calling it directly is flagged as a synchronous
// setState regardless of its own internal deferral, so defer the call
// itself by a microtask too — same timing for the user.
queueMicrotask(() => fetchSearch(q, 1));
return () => abortRef.current?.abort();
}, [q, fetchSearch]);

View File

@@ -263,6 +263,9 @@ export function UserPublicProfile() {
useEffect(() => {
if (!lastUserEvent) return;
const { user } = lastUserEvent;
// Subscribing to the WS-pushed user-update stream — setState here
// mirrors external state into local state, no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState((s) => {
if (s.status !== "loaded" || s.user.id !== user.id) return s;
return { ...s, user };
@@ -298,12 +301,17 @@ export function UserPublicProfile() {
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
prevMyVotesRef.current = null;
setTab("dumps");
setFollowedState(null);
setInviteTreeState(null);
}
// Refs can't be written during render — reset in an effect instead, which
// still runs (and clears the ref) before the vote-diffing effect below.
useEffect(() => {
prevMyVotesRef.current = null;
}, [username]);
useEffect(() => {
if (!username) return;
const controller = new AbortController();
@@ -528,7 +536,7 @@ export function UserPublicProfile() {
) {
return;
}
setFollowedState({ status: "loading" });
queueMicrotask(() => setFollowedState({ status: "loading" }));
const controller = new AbortController();
Promise.all([
fetch(
@@ -578,7 +586,7 @@ export function UserPublicProfile() {
) {
return;
}
setInviteTreeState({ status: "loading" });
queueMicrotask(() => setInviteTreeState({ status: "loading" }));
const controller = new AbortController();
fetch(
`${API_URL}/api/users/${username}/invitees`,
@@ -1396,7 +1404,6 @@ function UpvotedDumpList(
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes);
return;

View File

@@ -70,7 +70,6 @@ export function UserUpvoted() {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
return;