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 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 {
|
||||||
|
|||||||
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,
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface Dump {
|
|||||||
voteCount: number;
|
voteCount: number;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
|
thumbnailMime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/")) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/")) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 ?"
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user