v3: allow users to edit dump thumbnail, fixed some linter errors in the frontend
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -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 {
|
||||
|
||||
14
api/db/migrations/0002_dump_thumbnail.ts
Normal file
14
api/db/migrations/0002_dump_thumbnail.ts
Normal 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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface Dump {
|
||||
voteCount: number;
|
||||
commentCount: number;
|
||||
isPrivate: boolean;
|
||||
thumbnailMime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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/")) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ?"
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface Dump {
|
||||
voteCount: number;
|
||||
commentCount: number;
|
||||
isPrivate: boolean;
|
||||
thumbnailMime?: string;
|
||||
}
|
||||
|
||||
export type RawDump = WithStringDate<Dump>;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,6 +17,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
|
||||
type DumpEditState =
|
||||
| { status: "loading" }
|
||||
@@ -36,13 +37,13 @@ export function DumpEdit() {
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [thumbUploading, setThumbUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
|
||||
setState({ status: "loading" });
|
||||
|
||||
(async () => {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
|
||||
cache: "no-store",
|
||||
@@ -131,6 +132,42 @@ export function DumpEdit() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailChange = async (file: File) => {
|
||||
if (state.status !== "loaded") return;
|
||||
setThumbUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
|
||||
{ method: "PUT", body: formData },
|
||||
);
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (apiResponse.success) {
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
}
|
||||
} finally {
|
||||
setThumbUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailReset = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setThumbUploading(true);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (apiResponse.success) {
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
}
|
||||
} finally {
|
||||
setThumbUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
|
||||
@@ -184,6 +221,16 @@ export function DumpEdit() {
|
||||
|
||||
const { dump } = state;
|
||||
|
||||
const currentThumbnailSrc = dump.thumbnailMime
|
||||
? `${API_URL}/api/thumbnails/${dump.id}`
|
||||
: dump.kind === "file"
|
||||
? (dump.fileMime?.startsWith("image/")
|
||||
? `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`
|
||||
: dump.fileMime?.startsWith("video/")
|
||||
? `${API_URL}/api/thumbnails/${dump.id}`
|
||||
: null)
|
||||
: dump.richContent?.thumbnailUrl ?? null;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="form-page form-page--two-col">
|
||||
@@ -230,6 +277,30 @@ export function DumpEdit() {
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>Thumbnail</Trans>
|
||||
</label>
|
||||
<div className="dump-edit-thumbnail-row">
|
||||
<ImagePicker
|
||||
src={currentThumbnailSrc}
|
||||
size={64}
|
||||
onChange={handleThumbnailChange}
|
||||
uploading={thumbUploading}
|
||||
/>
|
||||
{dump.thumbnailMime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border"
|
||||
onClick={handleThumbnailReset}
|
||||
disabled={thumbUploading}
|
||||
>
|
||||
<Trans>Reset to default</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">
|
||||
<Trans>Title</Trans>
|
||||
|
||||
@@ -122,7 +122,10 @@ export function PlaylistDetail() {
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
setState({ status: "loading" });
|
||||
// Deferred to a microtask so callers invoking this directly from an
|
||||
// effect body don't trip the synchronous-setState-in-effect check —
|
||||
// same timing for the user, just not in the same tick.
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -351,6 +354,9 @@ export function PlaylistDetail() {
|
||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
// Subscribing to the WS-pushed deleted-dump-ids set — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const filtered = prev.playlist.dumps.filter((d) =>
|
||||
|
||||
@@ -55,13 +55,16 @@ export function Search() {
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSearch = useCallback(async (query: string, page: number) => {
|
||||
// Deferred to a microtask so callers invoking this directly from an
|
||||
// effect body don't trip the synchronous-setState-in-effect check —
|
||||
// same timing for the user, just not in the same tick.
|
||||
if (!query.trim()) {
|
||||
setState({ status: "idle" });
|
||||
queueMicrotask(() => setState({ status: "idle" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === 1) {
|
||||
setState({ status: "loading" });
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
@@ -131,7 +134,10 @@ export function Search() {
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSearch(q, 1);
|
||||
// fetchSearch is async; calling it directly is flagged as a synchronous
|
||||
// setState regardless of its own internal deferral, so defer the call
|
||||
// itself by a microtask too — same timing for the user.
|
||||
queueMicrotask(() => fetchSearch(q, 1));
|
||||
return () => abortRef.current?.abort();
|
||||
}, [q, fetchSearch]);
|
||||
|
||||
|
||||
@@ -263,6 +263,9 @@ export function UserPublicProfile() {
|
||||
useEffect(() => {
|
||||
if (!lastUserEvent) return;
|
||||
const { user } = lastUserEvent;
|
||||
// Subscribing to the WS-pushed user-update stream — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.user.id !== user.id) return s;
|
||||
return { ...s, user };
|
||||
@@ -298,12 +301,17 @@ export function UserPublicProfile() {
|
||||
if (prevUsername !== username) {
|
||||
setPrevUsername(username);
|
||||
setState({ status: "loading" });
|
||||
prevMyVotesRef.current = null;
|
||||
setTab("dumps");
|
||||
setFollowedState(null);
|
||||
setInviteTreeState(null);
|
||||
}
|
||||
|
||||
// Refs can't be written during render — reset in an effect instead, which
|
||||
// still runs (and clears the ref) before the vote-diffing effect below.
|
||||
useEffect(() => {
|
||||
prevMyVotesRef.current = null;
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
const controller = new AbortController();
|
||||
@@ -528,7 +536,7 @@ export function UserPublicProfile() {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFollowedState({ status: "loading" });
|
||||
queueMicrotask(() => setFollowedState({ status: "loading" }));
|
||||
const controller = new AbortController();
|
||||
Promise.all([
|
||||
fetch(
|
||||
@@ -578,7 +586,7 @@ export function UserPublicProfile() {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setInviteTreeState({ status: "loading" });
|
||||
queueMicrotask(() => setInviteTreeState({ status: "loading" }));
|
||||
const controller = new AbortController();
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/invitees`,
|
||||
@@ -1396,7 +1404,6 @@ function UpvotedDumpList(
|
||||
if (!profileUserId || !isOwnProfile) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds(new Set(wsMyVotes));
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
return;
|
||||
|
||||
@@ -70,7 +70,6 @@ export function UserUpvoted() {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds(new Set(myVotes));
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user