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 type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts"; import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -11,6 +12,7 @@ interface Migration {
// straight from schema.sql, where the change it makes already exists. // straight from schema.sql, where the change it makes already exists.
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0001_comment_likes", up: up0001CommentLikes },
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
]; ];
export function runMigrations(db: DatabaseSync): void { 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, file_size INTEGER,
vote_count INTEGER NOT NULL DEFAULT 0, vote_count INTEGER NOT NULL DEFAULT 0,
is_private 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 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import {
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts"; import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts"; import { parsePagination } from "../lib/pagination.ts";
import { validateImageUpload } from "../lib/upload.ts";
import { THUMBNAILS_DIR } from "../config.ts";
import { import {
createFileDump, createFileDump,
createUrlDump, createUrlDump,
@@ -20,7 +22,9 @@ import {
getDump, getDump,
listDumps, listDumps,
refreshDumpMetadata, refreshDumpMetadata,
removeDumpThumbnail,
replaceFileDump, replaceFileDump,
setDumpThumbnail,
updateDump, updateDump,
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts"; import { getDumpVoters } from "../services/vote-service.ts";
@@ -175,6 +179,66 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody; 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) => { router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId; const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId; const userId = ctx.state.user?.userId;

View File

@@ -1,6 +1,7 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; 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"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
const router = new Router({ prefix: "/api/thumbnails" }); 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"); 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); const dump = getDump(dumpId);
if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) { if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) {

View File

@@ -22,6 +22,7 @@ import {
DUMP_ALLOWED_MIME_TYPES, DUMP_ALLOWED_MIME_TYPES,
DUMP_MAX_FILE_SIZE_BYTES, DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR, DUMPS_DIR,
THUMBNAILS_DIR,
} from "../config.ts"; } from "../config.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
@@ -39,13 +40,13 @@ function titleFromUrl(url: string): string {
} }
const BASE_COLS = 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}, const SELECT_COLS = `${BASE_COLS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
const SELECT_COLS_ALIASED = 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"; " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
export async function createUrlDump( export async function createUrlDump(
@@ -566,6 +567,36 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
return updatedDump; 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> { export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId); const dump = fetchDump(dumpId);

View File

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

View File

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

View File

@@ -96,7 +96,9 @@ export function DumpCreateModal(
const [submitState, setSubmitState] = useState<SubmitState>({ const [submitState, setSubmitState] = useState<SubmitState>({
status: "idle", status: "idle",
}); });
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" }); const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
status: "idle",
});
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Playlist phase state // Playlist phase state
@@ -109,40 +111,54 @@ export function DumpCreateModal(
setTitleEditing(false); 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 // Debounced URL preview
useEffect(() => { useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
if (!canonicalUrl) return;
let trimmed: string; // Deferred to a microtask (rather than called directly) so the immediate
try { // "loading" feedback doesn't fire synchronously within the effect body —
const u = new URL(normalizeUrl(url)); // same timing for the user, just not in the same tick.
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(); queueMicrotask(() => setUrlFetchState({ status: "loading" }));
trimmed = u.toString();
} catch {
setUrlPreview({ status: "idle" });
return;
}
setUrlPreview({ status: "loading" }); debounceRef.current = setTimeout(() => {
debounceRef.current = setTimeout(async () => { fetch(`${API_URL}/api/preview?url=${encodeURIComponent(canonicalUrl)}`)
try { .then((res) => res.json())
const res = await fetch( .then((body) => {
`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`, setUrlFetchState({
);
const body = await res.json();
setUrlPreview({
status: "done", status: "done",
richContent: body.success ? body.data : null, richContent: body.success ? body.data : null,
}); });
} catch { })
setUrlPreview({ status: "done", richContent: null }); .catch(() => {
} setUrlFetchState({ status: "done", richContent: null });
});
}, 600); }, 600);
return () => { return () => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
}; };
}, [url]); }, [canonicalUrl]);
// Paste handler // Paste handler
useEffect(() => { useEffect(() => {
@@ -151,7 +167,6 @@ export function DumpCreateModal(
if (pastedFile) { if (pastedFile) {
setMode("file"); setMode("file");
setUrl(""); setUrl("");
setUrlPreview({ status: "idle" });
selectFile(pastedFile); selectFile(pastedFile);
setSubmitState({ status: "idle" }); setSubmitState({ status: "idle" });
return; return;
@@ -310,7 +325,6 @@ export function DumpCreateModal(
onClick={() => { onClick={() => {
setMode("file"); setMode("file");
setUrl(""); setUrl("");
setUrlPreview({ status: "idle" });
setSubmitState({ status: "idle" }); setSubmitState({ status: "idle" });
}} }}
disabled={submitting} disabled={submitting}
@@ -346,7 +360,6 @@ export function DumpCreateModal(
e.preventDefault(); e.preventDefault();
setMode("file"); setMode("file");
setUrl(""); setUrl("");
setUrlPreview({ status: "idle" });
selectFile(pastedFile); selectFile(pastedFile);
setSubmitState({ status: "idle" }); 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 fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
const mime = dump.fileMime ?? ""; const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl; const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
const thumbOverride = dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}`
: null;
if (compact) { if (compact) {
if (mime.startsWith("image/")) { if (mime.startsWith("image/")) {
return ( return (
<img <img
src={fileUrl} src={thumbOverride ?? fileUrl}
alt={dump.fileName} alt={dump.fileName}
className="rich-content-compact-thumbnail" className="rich-content-compact-thumbnail"
onError={(e) => { onError={(e) => {
@@ -194,10 +197,19 @@ export default function FilePreview(
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title }); 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> </button>
); );
} }
if (thumbOverride) {
return <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />;
}
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>; return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
} }
@@ -225,6 +237,7 @@ export default function FilePreview(
> >
<video <video
src={fileUrl} src={fileUrl}
poster={thumbOverride ?? undefined}
preload="none" preload="none"
className="file-preview-video-thumb" className="file-preview-video-thumb"
muted muted
@@ -235,7 +248,14 @@ export default function FilePreview(
</button> </button>
); );
} }
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />; return (
<MediaPlayer
src={fileUrl}
kind="video"
mime={mime}
poster={thumbOverride ?? undefined}
/>
);
} }
if (mime.startsWith("audio/")) { if (mime.startsWith("audio/")) {

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx"; import FilePreview from "../components/FilePreview.tsx";
import { TextEditor } from "../components/TextEditor.tsx"; import { TextEditor } from "../components/TextEditor.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx"; import { FileDropZone } from "../components/FileDropZone.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
type DumpEditState = type DumpEditState =
| { status: "loading" } | { status: "loading" }
@@ -36,13 +37,13 @@ export function DumpEdit() {
const [newFile, setNewFile] = useState<File | null>(null); const [newFile, setNewFile] = useState<File | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [thumbUploading, setThumbUploading] = useState(false);
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
setState({ status: "loading" });
(async () => { (async () => {
setState({ status: "loading" });
try { try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store", 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 () => { const handleDelete = async () => {
if (state.status !== "loaded") return; if (state.status !== "loaded") return;
@@ -184,6 +221,16 @@ export function DumpEdit() {
const { dump } = state; 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 ( return (
<PageShell> <PageShell>
<div className="form-page form-page--two-col"> <div className="form-page form-page--two-col">
@@ -230,6 +277,30 @@ export function DumpEdit() {
handleSave(); 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"> <div className="form-group">
<label htmlFor="title"> <label htmlFor="title">
<Trans>Title</Trans> <Trans>Title</Trans>

View File

@@ -122,7 +122,10 @@ export function PlaylistDetail() {
fetchAbortRef.current?.abort(); fetchAbortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
fetchAbortRef.current = controller; 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}`, { fetch(`${API_URL}/api/playlists/${playlistId}`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {}, 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) // Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
useEffect(() => { useEffect(() => {
if (deletedDumpIds.size === 0) return; 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) => { setState((prev) => {
if (prev.status !== "loaded") return prev; if (prev.status !== "loaded") return prev;
const filtered = prev.playlist.dumps.filter((d) => const filtered = prev.playlist.dumps.filter((d) =>

View File

@@ -55,13 +55,16 @@ export function Search() {
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const fetchSearch = useCallback(async (query: string, page: number) => { 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()) { if (!query.trim()) {
setState({ status: "idle" }); queueMicrotask(() => setState({ status: "idle" }));
return; return;
} }
if (page === 1) { if (page === 1) {
setState({ status: "loading" }); queueMicrotask(() => setState({ status: "loading" }));
} }
abortRef.current?.abort(); abortRef.current?.abort();
@@ -131,7 +134,10 @@ export function Search() {
}, [token]); }, [token]);
useEffect(() => { 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(); return () => abortRef.current?.abort();
}, [q, fetchSearch]); }, [q, fetchSearch]);

View File

@@ -263,6 +263,9 @@ export function UserPublicProfile() {
useEffect(() => { useEffect(() => {
if (!lastUserEvent) return; if (!lastUserEvent) return;
const { user } = lastUserEvent; 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) => { setState((s) => {
if (s.status !== "loaded" || s.user.id !== user.id) return s; if (s.status !== "loaded" || s.user.id !== user.id) return s;
return { ...s, user }; return { ...s, user };
@@ -298,12 +301,17 @@ export function UserPublicProfile() {
if (prevUsername !== username) { if (prevUsername !== username) {
setPrevUsername(username); setPrevUsername(username);
setState({ status: "loading" }); setState({ status: "loading" });
prevMyVotesRef.current = null;
setTab("dumps"); setTab("dumps");
setFollowedState(null); setFollowedState(null);
setInviteTreeState(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(() => { useEffect(() => {
if (!username) return; if (!username) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -528,7 +536,7 @@ export function UserPublicProfile() {
) { ) {
return; return;
} }
setFollowedState({ status: "loading" }); queueMicrotask(() => setFollowedState({ status: "loading" }));
const controller = new AbortController(); const controller = new AbortController();
Promise.all([ Promise.all([
fetch( fetch(
@@ -578,7 +586,7 @@ export function UserPublicProfile() {
) { ) {
return; return;
} }
setInviteTreeState({ status: "loading" }); queueMicrotask(() => setInviteTreeState({ status: "loading" }));
const controller = new AbortController(); const controller = new AbortController();
fetch( fetch(
`${API_URL}/api/users/${username}/invitees`, `${API_URL}/api/users/${username}/invitees`,
@@ -1396,7 +1404,6 @@ function UpvotedDumpList(
if (!profileUserId || !isOwnProfile) return; if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) { if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent. // setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(wsMyVotes)); setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes); prevMyVotesRef.current = new Set(wsMyVotes);
return; return;

View File

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