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

@@ -1,5 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
interface Migration {
name: string;
@@ -11,6 +12,7 @@ interface Migration {
// straight from schema.sql, where the change it makes already exists.
const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes },
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,14 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with this column) or an existing one that predates it.
export function up(db: DatabaseSync): void {
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;
}[];
if (!dumpCols.some((c) => c.name === "custom_thumbnail_mime")) {
db.exec(
`ALTER TABLE dumps ADD COLUMN custom_thumbnail_mime TEXT;`,
);
}
}

View File

@@ -14,6 +14,7 @@ CREATE TABLE dumps (
file_size INTEGER,
vote_count INTEGER NOT NULL DEFAULT 0,
is_private INTEGER NOT NULL DEFAULT 0,
custom_thumbnail_mime TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@@ -79,6 +79,7 @@ export interface DumpRow {
vote_count: number;
comment_count: number;
is_private: number;
custom_thumbnail_mime: string | null;
[key: string]: SQLOutputValue; // Index signature
}
@@ -126,7 +127,10 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
(typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number" &&
"comment_count" in obj && typeof obj.comment_count === "number" &&
"is_private" in obj && typeof obj.is_private === "number";
"is_private" in obj && typeof obj.is_private === "number" &&
"custom_thumbnail_mime" in obj &&
(typeof obj.custom_thumbnail_mime === "string" ||
obj.custom_thumbnail_mime === null);
}
export function isUserRow(obj: unknown): obj is UserRow {
@@ -172,6 +176,7 @@ export function dumpRowToApi(row: DumpRow): Dump {
voteCount: row.vote_count,
commentCount: row.comment_count,
isPrivate: Boolean(row.is_private),
thumbnailMime: row.custom_thumbnail_mime ?? undefined,
};
}
@@ -193,6 +198,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
vote_count: dump.voteCount,
comment_count: dump.commentCount,
is_private: dump.isPrivate ? 1 : 0,
custom_thumbnail_mime: dump.thumbnailMime ?? null,
};
}

View File

@@ -32,6 +32,7 @@ export interface Dump {
voteCount: number;
commentCount: number;
isPrivate: boolean;
thumbnailMime?: string;
}
/**

View File

@@ -13,6 +13,8 @@ import {
import { authMiddleware } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { validateImageUpload } from "../lib/upload.ts";
import { THUMBNAILS_DIR } from "../config.ts";
import {
createFileDump,
createUrlDump,
@@ -20,7 +22,9 @@ import {
getDump,
listDumps,
refreshDumpMetadata,
removeDumpThumbnail,
replaceFileDump,
setDumpThumbnail,
updateDump,
} from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
@@ -175,6 +179,66 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Missing file field",
);
}
const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data);
// DB update first (resolves slug → id), then file write.
const updatedDump = setDumpThumbnail(dump.id, mime);
const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`;
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
try {
await Deno.writeFile(filePath, data);
} catch (err) {
await Deno.remove(filePath).catch(() => {});
throw err;
}
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const updatedDump = await removeDumpThumbnail(dump.id);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;

View File

@@ -1,6 +1,7 @@
import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump } from "../services/dump-service.ts";
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
const router = new Router({ prefix: "/api/thumbnails" });
@@ -53,6 +54,16 @@ router.get("/:dumpId", async (ctx) => {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
}
const customThumb = getDumpThumbnailInfo(dumpId);
if (customThumb) {
const served = await serveUploadedFile(
ctx,
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
customThumb.mime,
);
if (served) return;
}
const dump = getDump(dumpId);
if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) {

View File

@@ -22,6 +22,7 @@ import {
DUMP_ALLOWED_MIME_TYPES,
DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR,
THUMBNAILS_DIR,
} from "../config.ts";
import { linkAttachments } from "./attachment-service.ts";
@@ -39,13 +40,13 @@ function titleFromUrl(url: string): string {
}
const BASE_COLS =
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
const SELECT_COLS = `${BASE_COLS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
const SELECT_COLS_ALIASED =
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime," +
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
export async function createUrlDump(
@@ -566,6 +567,36 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
return updatedDump;
}
export function getDumpThumbnailInfo(
idOrSlug: string,
): { id: string; mime: string } | undefined {
const dump = fetchDump(idOrSlug);
return dump.thumbnailMime
? { id: dump.id, mime: dump.thumbnailMime }
: undefined;
}
export function setDumpThumbnail(dumpId: string, mime: string): Dump {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = ? WHERE id = ?;`).run(
mime,
dump.id,
);
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
.run(dump.id);
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId);

View File

@@ -2389,6 +2389,12 @@ body.has-player .fab-new {
margin-top: 0.75rem;
}
.dump-edit-thumbnail-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.dump-form {
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,7 @@
import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
import { API_URL } from "../config/api.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpUrl } from "../utils/urls.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
@@ -48,7 +49,15 @@ export function DumpCard(
{dump.kind === "file"
? <FilePreview dump={dump} compact />
: dump.richContent
? <RichContentCard richContent={dump.richContent} compact />
? (
<RichContentCard
richContent={dump.richContent}
compact
thumbnailOverrideUrl={dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}`
: undefined}
/>
)
: <span className="dump-card-preview-icon">🔗</span>}
</div>

View File

@@ -96,7 +96,9 @@ export function DumpCreateModal(
const [submitState, setSubmitState] = useState<SubmitState>({
status: "idle",
});
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
status: "idle",
});
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Playlist phase state
@@ -109,40 +111,54 @@ export function DumpCreateModal(
setTitleEditing(false);
};
// Canonical form of `url`, used (rather than the raw input text) to decide
// whether the preview actually needs to re-fetch — e.g. "example.com" and
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
// to the same value, so they shouldn't trigger two separate fetches.
let canonicalUrl: string | null;
try {
const u = new URL(normalizeUrl(url));
canonicalUrl = (u.protocol === "http:" || u.protocol === "https:")
? u.toString()
: null;
} catch {
canonicalUrl = null;
}
// Idle state is derived below from canonicalUrl rather than set here, so
// this effect only ever needs to set loading/done (inside the timeout).
const urlPreview: UrlPreview = canonicalUrl
? urlFetchState
: { status: "idle" };
// Debounced URL preview
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!canonicalUrl) return;
let trimmed: string;
try {
const u = new URL(normalizeUrl(url));
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
trimmed = u.toString();
} catch {
setUrlPreview({ status: "idle" });
return;
}
// Deferred to a microtask (rather than called directly) so the immediate
// "loading" feedback doesn't fire synchronously within the effect body —
// same timing for the user, just not in the same tick.
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
setUrlPreview({ status: "loading" });
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`,
);
const body = await res.json();
setUrlPreview({
debounceRef.current = setTimeout(() => {
fetch(`${API_URL}/api/preview?url=${encodeURIComponent(canonicalUrl)}`)
.then((res) => res.json())
.then((body) => {
setUrlFetchState({
status: "done",
richContent: body.success ? body.data : null,
});
} catch {
setUrlPreview({ status: "done", richContent: null });
}
})
.catch(() => {
setUrlFetchState({ status: "done", richContent: null });
});
}, 600);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [url]);
}, [canonicalUrl]);
// Paste handler
useEffect(() => {
@@ -151,7 +167,6 @@ export function DumpCreateModal(
if (pastedFile) {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
selectFile(pastedFile);
setSubmitState({ status: "idle" });
return;
@@ -310,7 +325,6 @@ export function DumpCreateModal(
onClick={() => {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setSubmitState({ status: "idle" });
}}
disabled={submitting}
@@ -346,7 +360,6 @@ export function DumpCreateModal(
e.preventDefault();
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
selectFile(pastedFile);
setSubmitState({ status: "idle" });
}

View File

@@ -148,12 +148,15 @@ export default function FilePreview(
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
const thumbOverride = dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}`
: null;
if (compact) {
if (mime.startsWith("image/")) {
return (
<img
src={fileUrl}
src={thumbOverride ?? fileUrl}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
onError={(e) => {
@@ -194,10 +197,19 @@ export default function FilePreview(
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}}
>
<span className="rich-content-compact-icon">{mimeIcon(mime)}</span>
{thumbOverride
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
: (
<span className="rich-content-compact-icon">
{mimeIcon(mime)}
</span>
)}
</button>
);
}
if (thumbOverride) {
return <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />;
}
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
}
@@ -225,6 +237,7 @@ export default function FilePreview(
>
<video
src={fileUrl}
poster={thumbOverride ?? undefined}
preload="none"
className="file-preview-video-thumb"
muted
@@ -235,7 +248,14 @@ export default function FilePreview(
</button>
);
}
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
return (
<MediaPlayer
src={fileUrl}
kind="video"
mime={mime}
poster={thumbOverride ?? undefined}
/>
);
}
if (mime.startsWith("audio/")) {

View File

@@ -127,6 +127,7 @@ interface MediaPlayerProps {
src: string;
kind: "audio" | "video";
mime?: string;
poster?: string;
autoplay?: boolean;
onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => void;
@@ -139,6 +140,7 @@ export function MediaPlayer(
src,
kind,
mime,
poster,
autoplay,
onPlayStateChange,
onTimeUpdate,
@@ -422,6 +424,7 @@ export function MediaPlayer(
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
src={src}
poster={poster}
preload="metadata"
className="video-player-video"
onClick={toggle}

View File

@@ -20,12 +20,18 @@ function proxyIfHttp(url: string): string {
interface RichContentCardProps {
richContent: RichContent;
compact?: boolean;
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
thumbnailOverrideUrl?: string;
}
export default function RichContentCard(
{ richContent, compact = false }: RichContentCardProps,
{ richContent, compact = false, thumbnailOverrideUrl }: RichContentCardProps,
) {
const { play, current, playing } = useContext(PlayerContext);
const thumbnailSrc = thumbnailOverrideUrl ??
(richContent.thumbnailUrl
? proxyIfHttp(richContent.thumbnailUrl)
: undefined);
if (compact) {
if (richContent.embedUrl) {
@@ -50,10 +56,10 @@ export default function RichContentCard(
}}
aria-label={isPlaying ? "Pause" : "Play"}
>
{richContent.thumbnailUrl
{thumbnailSrc
? (
<img
src={proxyIfHttp(richContent.thumbnailUrl!)}
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
onError={(e) => {
@@ -77,10 +83,10 @@ export default function RichContentCard(
className="rich-content-compact"
onClick={(e) => e.stopPropagation()}
>
{richContent.thumbnailUrl
{thumbnailSrc
? (
<img
src={proxyIfHttp(richContent.thumbnailUrl!)}
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
onError={(e) => {
@@ -95,7 +101,7 @@ export default function RichContentCard(
const canPlay = !!richContent.embedUrl;
const thumbnail = richContent.thumbnailUrl && (
const thumbnail = thumbnailSrc && (
canPlay
? (
<button
@@ -111,7 +117,7 @@ export default function RichContentCard(
aria-label="Play"
>
<img
src={proxyIfHttp(richContent.thumbnailUrl!)}
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
onError={(e) => {
@@ -123,7 +129,7 @@ export default function RichContentCard(
)
: (
<img
src={proxyIfHttp(richContent.thumbnailUrl!)}
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
onError={(e) => {

View File

@@ -105,11 +105,11 @@ export function UserListPopover(
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("scroll", onScroll, true);
globalThis.addEventListener("scroll", onScroll, true);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("scroll", onScroll, true);
globalThis.removeEventListener("scroll", onScroll, true);
};
}, [onClose, anchorRef]);

View File

@@ -22,9 +22,14 @@ export function FollowProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!token) {
// Deferred to a microtask (rather than called directly) so this
// doesn't fire synchronously within the effect body — same timing
// for the user, just not in the same tick.
queueMicrotask(() => {
setFollowedUserIds(new Set());
setFollowedPlaylistIds(new Set());
setIsLoaded(false);
});
return;
}
const controller = new AbortController();

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@ msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:95
#: src/components/DumpCard.tsx:104
#: src/components/JournalCard.tsx:96
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -60,8 +60,8 @@ msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:224
#: src/pages/Dump.tsx:427
#: src/pages/DumpEdit.tsx:177
#: src/pages/Dump.tsx:434
#: src/pages/DumpEdit.tsx:215
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -203,7 +203,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/DumpCreateModal.tsx:461
#: src/components/PlaylistCreateForm.tsx:112
#: src/pages/Dump.tsx:330
#: src/pages/DumpEdit.tsx:320
#: src/pages/DumpEdit.tsx:392
#: src/pages/PlaylistDetail.tsx:680
#: src/pages/UserPublicProfile.tsx:842
#: src/pages/UserPublicProfile.tsx:924
@@ -266,7 +266,7 @@ msgstr "Could not change password"
msgid "Could not connect to server"
msgstr "Could not connect to server"
#: src/components/UserListPopover.tsx:100
#: src/components/UserListPopover.tsx:141
msgid "Could not load."
msgstr "Could not load."
@@ -309,8 +309,8 @@ msgstr "Dark"
msgid "Delete"
msgstr "Delete"
#: src/pages/DumpEdit.tsx:316
#: src/pages/DumpEdit.tsx:338
#: src/pages/DumpEdit.tsx:388
#: src/pages/DumpEdit.tsx:410
msgid "Delete dump"
msgstr "Delete dump"
@@ -324,7 +324,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?"
msgstr "Delete this comment?"
#: src/pages/DumpEdit.tsx:337
#: src/pages/DumpEdit.tsx:409
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
@@ -346,7 +346,7 @@ msgstr "Done"
msgid "Drop a file here"
msgstr "Drop a file here"
#: src/pages/DumpEdit.tsx:273
#: src/pages/DumpEdit.tsx:345
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
@@ -379,7 +379,7 @@ msgid "Earlier"
msgstr "Earlier"
#: src/components/CommentThread.tsx:323
#: src/pages/Dump.tsx:423
#: src/pages/Dump.tsx:430
#: src/pages/PlaylistDetail.tsx:706
msgid "Edit"
msgstr "Edit"
@@ -406,7 +406,7 @@ msgstr "edited {0}"
msgid "Edited {0}"
msgstr "Edited {0}"
#: src/pages/DumpEdit.tsx:192
#: src/pages/DumpEdit.tsx:240
msgid "Editing"
msgstr "Editing"
@@ -600,13 +600,13 @@ msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
#: src/components/UserListPopover.tsx:188
#: src/components/UserListPopover.tsx:231
#: src/pages/Notifications.tsx:440
msgid "Load more"
msgstr "Load more"
#: src/pages/Dump.tsx:200
#: src/pages/DumpEdit.tsx:153
#: src/pages/DumpEdit.tsx:191
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -632,8 +632,8 @@ msgstr "Loading profile…"
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:149
#: src/components/UserListPopover.tsx:187
#: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44
@@ -731,7 +731,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/UserListPopover.tsx:157
#: src/components/UserListPopover.tsx:200
msgid "No one yet."
msgstr "No one yet."
@@ -832,7 +832,7 @@ msgstr "Post reply"
msgid "Posting…"
msgstr "Posting…"
#: src/components/DumpCard.tsx:104
#: src/components/DumpCard.tsx:113
#: src/components/JournalCard.tsx:105
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
@@ -843,7 +843,7 @@ msgstr "private"
#: src/components/DumpCreateModal.tsx:450
#: src/components/PlaylistCreateForm.tsx:99
#: src/pages/DumpEdit.tsx:306
#: src/pages/DumpEdit.tsx:378
#: src/pages/PlaylistDetail.tsx:746
msgid "Private"
msgstr "Private"
@@ -855,16 +855,16 @@ msgstr "public"
#: src/components/DumpCreateModal.tsx:442
#: src/components/PlaylistCreateForm.tsx:92
#: src/pages/DumpEdit.tsx:299
#: src/pages/DumpEdit.tsx:371
#: src/pages/PlaylistDetail.tsx:739
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:221
#: src/pages/DumpEdit.tsx:269
msgid "Refresh metadata"
msgstr "Refresh metadata"
#: src/pages/DumpEdit.tsx:220
#: src/pages/DumpEdit.tsx:268
msgid "Refreshing…"
msgstr "Refreshing…"
@@ -897,7 +897,7 @@ msgstr "Remove like"
msgid "Remove vote"
msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:272
#: src/pages/DumpEdit.tsx:344
msgid "Replace file"
msgstr "Replace file"
@@ -913,14 +913,18 @@ msgstr "Request failed"
msgid "Reset failed"
msgstr "Reset failed"
#: src/pages/DumpEdit.tsx:299
msgid "Reset to default"
msgstr "Reset to default"
#: src/pages/Dump.tsx:217
#: src/pages/DumpEdit.tsx:170
#: src/pages/DumpEdit.tsx:208
msgid "Retry"
msgstr "Retry"
#: src/components/CommentThread.tsx:275
#: src/pages/Dump.tsx:322
#: src/pages/DumpEdit.tsx:329
#: src/pages/DumpEdit.tsx:401
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/UserPublicProfile.tsx:834
#: src/pages/UserPublicProfile.tsx:916
@@ -1003,9 +1007,13 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed."
#: src/pages/DumpEdit.tsx:283
msgid "Thumbnail"
msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:384
#: src/components/PlaylistCreateForm.tsx:72
#: src/pages/DumpEdit.tsx:235
#: src/pages/DumpEdit.tsx:307
msgid "Title"
msgstr "Title"
@@ -1053,7 +1061,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:335
#: src/pages/DumpEdit.tsx:251
#: src/pages/DumpEdit.tsx:323
msgid "URL"
msgstr "URL"
@@ -1087,7 +1095,7 @@ msgid "View dump →"
msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:429
#: src/pages/DumpEdit.tsx:287
#: src/pages/DumpEdit.tsx:359
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
@@ -1097,7 +1105,7 @@ msgid "Who am I?"
msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:422
#: src/pages/DumpEdit.tsx:281
#: src/pages/DumpEdit.tsx:353
msgid "Why?"
msgstr "Why?"

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@ msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:95
#: src/components/DumpCard.tsx:104
#: src/components/JournalCard.tsx:96
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -60,8 +60,8 @@ msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:224
#: src/pages/Dump.tsx:427
#: src/pages/DumpEdit.tsx:177
#: src/pages/Dump.tsx:434
#: src/pages/DumpEdit.tsx:215
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -203,7 +203,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/DumpCreateModal.tsx:461
#: src/components/PlaylistCreateForm.tsx:112
#: src/pages/Dump.tsx:330
#: src/pages/DumpEdit.tsx:320
#: src/pages/DumpEdit.tsx:392
#: src/pages/PlaylistDetail.tsx:680
#: src/pages/UserPublicProfile.tsx:842
#: src/pages/UserPublicProfile.tsx:924
@@ -266,7 +266,7 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not connect to server"
msgstr "Impossible de contacter le serveur"
#: src/components/UserListPopover.tsx:100
#: src/components/UserListPopover.tsx:141
msgid "Could not load."
msgstr "Impossible de charger."
@@ -309,8 +309,8 @@ msgstr "Sombre"
msgid "Delete"
msgstr "Supprimer"
#: src/pages/DumpEdit.tsx:316
#: src/pages/DumpEdit.tsx:338
#: src/pages/DumpEdit.tsx:388
#: src/pages/DumpEdit.tsx:410
msgid "Delete dump"
msgstr "Supprimer la reco"
@@ -324,7 +324,7 @@ msgstr "Supprimer la collection"
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
#: src/pages/DumpEdit.tsx:337
#: src/pages/DumpEdit.tsx:409
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
@@ -346,7 +346,7 @@ msgstr "Terminé"
msgid "Drop a file here"
msgstr "Déposez un fichier ici"
#: src/pages/DumpEdit.tsx:273
#: src/pages/DumpEdit.tsx:345
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
@@ -379,7 +379,7 @@ msgid "Earlier"
msgstr "Plus tôt"
#: src/components/CommentThread.tsx:323
#: src/pages/Dump.tsx:423
#: src/pages/Dump.tsx:430
#: src/pages/PlaylistDetail.tsx:706
msgid "Edit"
msgstr "Modifier"
@@ -406,7 +406,7 @@ msgstr "modifié {0}"
msgid "Edited {0}"
msgstr "Modifié le {0}"
#: src/pages/DumpEdit.tsx:192
#: src/pages/DumpEdit.tsx:240
msgid "Editing"
msgstr "Modification"
@@ -600,13 +600,13 @@ msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
#: src/components/UserListPopover.tsx:188
#: src/components/UserListPopover.tsx:231
#: src/pages/Notifications.tsx:440
msgid "Load more"
msgstr "Charger plus"
#: src/pages/Dump.tsx:200
#: src/pages/DumpEdit.tsx:153
#: src/pages/DumpEdit.tsx:191
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -632,8 +632,8 @@ msgstr "Chargement du profil…"
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:149
#: src/components/UserListPopover.tsx:187
#: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44
@@ -731,7 +731,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/UserListPopover.tsx:157
#: src/components/UserListPopover.tsx:200
msgid "No one yet."
msgstr "Personne pour le moment."
@@ -832,7 +832,7 @@ msgstr "Publier la réponse"
msgid "Posting…"
msgstr "Publication…"
#: src/components/DumpCard.tsx:104
#: src/components/DumpCard.tsx:113
#: src/components/JournalCard.tsx:105
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
@@ -843,7 +843,7 @@ msgstr "privé"
#: src/components/DumpCreateModal.tsx:450
#: src/components/PlaylistCreateForm.tsx:99
#: src/pages/DumpEdit.tsx:306
#: src/pages/DumpEdit.tsx:378
#: src/pages/PlaylistDetail.tsx:746
msgid "Private"
msgstr "Privé"
@@ -855,16 +855,16 @@ msgstr "public"
#: src/components/DumpCreateModal.tsx:442
#: src/components/PlaylistCreateForm.tsx:92
#: src/pages/DumpEdit.tsx:299
#: src/pages/DumpEdit.tsx:371
#: src/pages/PlaylistDetail.tsx:739
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:221
#: src/pages/DumpEdit.tsx:269
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
#: src/pages/DumpEdit.tsx:220
#: src/pages/DumpEdit.tsx:268
msgid "Refreshing…"
msgstr "Actualisation…"
@@ -897,7 +897,7 @@ msgstr "Retirer le j'aime"
msgid "Remove vote"
msgstr "Retirer le vote"
#: src/pages/DumpEdit.tsx:272
#: src/pages/DumpEdit.tsx:344
msgid "Replace file"
msgstr "Remplacer le fichier"
@@ -913,14 +913,18 @@ msgstr "Échec de la demande"
msgid "Reset failed"
msgstr "Échec de la réinitialisation"
#: src/pages/DumpEdit.tsx:299
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
#: src/pages/Dump.tsx:217
#: src/pages/DumpEdit.tsx:170
#: src/pages/DumpEdit.tsx:208
msgid "Retry"
msgstr "Réessayer"
#: src/components/CommentThread.tsx:275
#: src/pages/Dump.tsx:322
#: src/pages/DumpEdit.tsx:329
#: src/pages/DumpEdit.tsx:401
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/UserPublicProfile.tsx:834
#: src/pages/UserPublicProfile.tsx:916
@@ -1003,9 +1007,13 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/DumpEdit.tsx:283
msgid "Thumbnail"
msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:384
#: src/components/PlaylistCreateForm.tsx:72
#: src/pages/DumpEdit.tsx:235
#: src/pages/DumpEdit.tsx:307
msgid "Title"
msgstr "Titre"
@@ -1053,7 +1061,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:335
#: src/pages/DumpEdit.tsx:251
#: src/pages/DumpEdit.tsx:323
msgid "URL"
msgstr "URL"
@@ -1087,7 +1095,7 @@ msgid "View dump →"
msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:429
#: src/pages/DumpEdit.tsx:287
#: src/pages/DumpEdit.tsx:359
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
@@ -1097,7 +1105,7 @@ msgid "Who am I?"
msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:422
#: src/pages/DumpEdit.tsx:281
#: src/pages/DumpEdit.tsx:353
msgid "Why?"
msgstr "Pourquoi ?"

View File

@@ -63,6 +63,7 @@ export interface Dump {
voteCount: number;
commentCount: number;
isPrivate: boolean;
thumbnailMime?: string;
}
export type RawDump = WithStringDate<Dump>;

View File

@@ -84,10 +84,9 @@ export function Dump() {
return () => controller.abort();
}
(async () => {
setDumpState({ status: "loading" });
setOp(null);
(async () => {
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;