v3: allow users to edit dump thumbnail, fixed some linter errors in the frontend
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
khannurien
2026-06-21 15:41:40 +00:00
parent 57cf55cc48
commit cf988ae608
27 changed files with 431 additions and 121 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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