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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user