From cf988ae608d9554ed234c30329f427502f7452da Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 21 Jun 2026 15:41:40 +0000 Subject: [PATCH] v3: allow users to edit dump thumbnail, fixed some linter errors in the frontend --- api/db/migrate.ts | 2 + api/db/migrations/0002_dump_thumbnail.ts | 14 +++++ api/db/schema.sql | 1 + api/model/db.ts | 8 ++- api/model/interfaces.ts | 1 + api/routes/dumps.ts | 64 ++++++++++++++++++++ api/routes/thumbnails.ts | 13 +++- api/services/dump-service.ts | 35 ++++++++++- src/App.css | 6 ++ src/components/DumpCard.tsx | 11 +++- src/components/DumpCreateModal.tsx | 67 ++++++++++++--------- src/components/FilePreview.tsx | 26 +++++++- src/components/MediaPlayer.tsx | 3 + src/components/RichContentCard.tsx | 22 ++++--- src/components/UserListPopover.tsx | 4 +- src/contexts/FollowProvider.tsx | 11 +++- src/locales/en.js | 2 +- src/locales/en.po | 64 +++++++++++--------- src/locales/fr.js | 2 +- src/locales/fr.po | 64 +++++++++++--------- src/model.ts | 1 + src/pages/Dump.tsx | 20 +++++-- src/pages/DumpEdit.tsx | 75 +++++++++++++++++++++++- src/pages/PlaylistDetail.tsx | 8 ++- src/pages/Search.tsx | 12 +++- src/pages/UserPublicProfile.tsx | 15 +++-- src/pages/UserUpvoted.tsx | 1 - 27 files changed, 431 insertions(+), 121 deletions(-) create mode 100644 api/db/migrations/0002_dump_thumbnail.ts diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 62203a3..e701616 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -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 { diff --git a/api/db/migrations/0002_dump_thumbnail.ts b/api/db/migrations/0002_dump_thumbnail.ts new file mode 100644 index 0000000..50e9e35 --- /dev/null +++ b/api/db/migrations/0002_dump_thumbnail.ts @@ -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;`, + ); + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 51e41cf..8d07061 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -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 ); diff --git a/api/model/db.ts b/api/model/db.ts index 1a205f8..588137b 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -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, }; } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index eb95096..2900a75 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -32,6 +32,7 @@ export interface Dump { voteCount: number; commentCount: number; isPrivate: boolean; + thumbnailMime?: string; } /** diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index befb50c..8238859 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -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 = { 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 = { 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; diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index dd24e28..5e84c99 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -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/")) { diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 05b9187..3b6a58c 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -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 { 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 { + 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 { const dump = fetchDump(dumpId); diff --git a/src/App.css b/src/App.css index a066c4e..63ed939 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx index 8ba2e50..4fe6809 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/DumpCard.tsx @@ -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" ? : dump.richContent - ? + ? ( + + ) : 🔗} diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 7c09197..57687d2 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -96,7 +96,9 @@ export function DumpCreateModal( const [submitState, setSubmitState] = useState({ status: "idle", }); - const [urlPreview, setUrlPreview] = useState({ status: "idle" }); + const [urlFetchState, setUrlFetchState] = useState({ + status: "idle", + }); const debounceRef = useRef | 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({ - status: "done", - richContent: body.success ? body.data : null, + 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(() => { + setUrlFetchState({ status: "done", richContent: null }); }); - } catch { - setUrlPreview({ 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" }); } diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 204d0e0..792001d 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -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 ( {dump.fileName} { @@ -194,10 +197,19 @@ export default function FilePreview( play({ kind: "file", fileUrl, mimeType: mime, title: dump.title }); }} > - {mimeIcon(mime)} + {thumbOverride + ? + : ( + + {mimeIcon(mime)} + + )} ); } + if (thumbOverride) { + return ; + } return {mimeIcon(mime)}; } @@ -225,6 +237,7 @@ export default function FilePreview( >