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,6 +1,7 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpUrl } from "../utils/urls.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
@@ -48,7 +49,15 @@ export function DumpCard(
|
||||
{dump.kind === "file"
|
||||
? <FilePreview dump={dump} compact />
|
||||
: dump.richContent
|
||||
? <RichContentCard richContent={dump.richContent} compact />
|
||||
? (
|
||||
<RichContentCard
|
||||
richContent={dump.richContent}
|
||||
compact
|
||||
thumbnailOverrideUrl={dump.thumbnailMime
|
||||
? `${API_URL}/api/thumbnails/${dump.id}`
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
: <span className="dump-card-preview-icon">🔗</span>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -96,7 +96,9 @@ export function DumpCreateModal(
|
||||
const [submitState, setSubmitState] = useState<SubmitState>({
|
||||
status: "idle",
|
||||
});
|
||||
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
|
||||
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
|
||||
status: "idle",
|
||||
});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Playlist phase state
|
||||
@@ -109,40 +111,54 @@ export function DumpCreateModal(
|
||||
setTitleEditing(false);
|
||||
};
|
||||
|
||||
// Canonical form of `url`, used (rather than the raw input text) to decide
|
||||
// whether the preview actually needs to re-fetch — e.g. "example.com" and
|
||||
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
|
||||
// to the same value, so they shouldn't trigger two separate fetches.
|
||||
let canonicalUrl: string | null;
|
||||
try {
|
||||
const u = new URL(normalizeUrl(url));
|
||||
canonicalUrl = (u.protocol === "http:" || u.protocol === "https:")
|
||||
? u.toString()
|
||||
: null;
|
||||
} catch {
|
||||
canonicalUrl = null;
|
||||
}
|
||||
|
||||
// Idle state is derived below from canonicalUrl rather than set here, so
|
||||
// this effect only ever needs to set loading/done (inside the timeout).
|
||||
const urlPreview: UrlPreview = canonicalUrl
|
||||
? urlFetchState
|
||||
: { status: "idle" };
|
||||
|
||||
// Debounced URL preview
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!canonicalUrl) return;
|
||||
|
||||
let trimmed: string;
|
||||
try {
|
||||
const u = new URL(normalizeUrl(url));
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
|
||||
trimmed = u.toString();
|
||||
} catch {
|
||||
setUrlPreview({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
// Deferred to a microtask (rather than called directly) so the immediate
|
||||
// "loading" feedback doesn't fire synchronously within the effect body —
|
||||
// same timing for the user, just not in the same tick.
|
||||
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
|
||||
|
||||
setUrlPreview({ status: "loading" });
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`,
|
||||
);
|
||||
const body = await res.json();
|
||||
setUrlPreview({
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -148,12 +148,15 @@ export default function FilePreview(
|
||||
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||
const mime = dump.fileMime ?? "";
|
||||
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
|
||||
const thumbOverride = dump.thumbnailMime
|
||||
? `${API_URL}/api/thumbnails/${dump.id}`
|
||||
: null;
|
||||
|
||||
if (compact) {
|
||||
if (mime.startsWith("image/")) {
|
||||
return (
|
||||
<img
|
||||
src={fileUrl}
|
||||
src={thumbOverride ?? fileUrl}
|
||||
alt={dump.fileName}
|
||||
className="rich-content-compact-thumbnail"
|
||||
onError={(e) => {
|
||||
@@ -194,10 +197,19 @@ export default function FilePreview(
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
|
||||
}}
|
||||
>
|
||||
<span className="rich-content-compact-icon">{mimeIcon(mime)}</span>
|
||||
{thumbOverride
|
||||
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
|
||||
: (
|
||||
<span className="rich-content-compact-icon">
|
||||
{mimeIcon(mime)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (thumbOverride) {
|
||||
return <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />;
|
||||
}
|
||||
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
|
||||
}
|
||||
|
||||
@@ -225,6 +237,7 @@ export default function FilePreview(
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
poster={thumbOverride ?? undefined}
|
||||
preload="none"
|
||||
className="file-preview-video-thumb"
|
||||
muted
|
||||
@@ -235,7 +248,14 @@ export default function FilePreview(
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
|
||||
return (
|
||||
<MediaPlayer
|
||||
src={fileUrl}
|
||||
kind="video"
|
||||
mime={mime}
|
||||
poster={thumbOverride ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mime.startsWith("audio/")) {
|
||||
|
||||
@@ -127,6 +127,7 @@ interface MediaPlayerProps {
|
||||
src: string;
|
||||
kind: "audio" | "video";
|
||||
mime?: string;
|
||||
poster?: string;
|
||||
autoplay?: boolean;
|
||||
onPlayStateChange?: (playing: boolean) => void;
|
||||
onTimeUpdate?: (time: number, duration: number) => void;
|
||||
@@ -139,6 +140,7 @@ export function MediaPlayer(
|
||||
src,
|
||||
kind,
|
||||
mime,
|
||||
poster,
|
||||
autoplay,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
@@ -422,6 +424,7 @@ export function MediaPlayer(
|
||||
<video
|
||||
ref={mediaRef as React.RefObject<HTMLVideoElement>}
|
||||
src={src}
|
||||
poster={poster}
|
||||
preload="metadata"
|
||||
className="video-player-video"
|
||||
onClick={toggle}
|
||||
|
||||
@@ -20,12 +20,18 @@ function proxyIfHttp(url: string): string {
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
compact?: boolean;
|
||||
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
|
||||
thumbnailOverrideUrl?: string;
|
||||
}
|
||||
|
||||
export default function RichContentCard(
|
||||
{ richContent, compact = false }: RichContentCardProps,
|
||||
{ richContent, compact = false, thumbnailOverrideUrl }: RichContentCardProps,
|
||||
) {
|
||||
const { play, current, playing } = useContext(PlayerContext);
|
||||
const thumbnailSrc = thumbnailOverrideUrl ??
|
||||
(richContent.thumbnailUrl
|
||||
? proxyIfHttp(richContent.thumbnailUrl)
|
||||
: undefined);
|
||||
|
||||
if (compact) {
|
||||
if (richContent.embedUrl) {
|
||||
@@ -50,10 +56,10 @@ export default function RichContentCard(
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{richContent.thumbnailUrl
|
||||
{thumbnailSrc
|
||||
? (
|
||||
<img
|
||||
src={proxyIfHttp(richContent.thumbnailUrl!)}
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-compact-thumbnail"
|
||||
onError={(e) => {
|
||||
@@ -77,10 +83,10 @@ export default function RichContentCard(
|
||||
className="rich-content-compact"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{richContent.thumbnailUrl
|
||||
{thumbnailSrc
|
||||
? (
|
||||
<img
|
||||
src={proxyIfHttp(richContent.thumbnailUrl!)}
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-compact-thumbnail"
|
||||
onError={(e) => {
|
||||
@@ -95,7 +101,7 @@ export default function RichContentCard(
|
||||
|
||||
const canPlay = !!richContent.embedUrl;
|
||||
|
||||
const thumbnail = richContent.thumbnailUrl && (
|
||||
const thumbnail = thumbnailSrc && (
|
||||
canPlay
|
||||
? (
|
||||
<button
|
||||
@@ -111,7 +117,7 @@ export default function RichContentCard(
|
||||
aria-label="Play"
|
||||
>
|
||||
<img
|
||||
src={proxyIfHttp(richContent.thumbnailUrl!)}
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
onError={(e) => {
|
||||
@@ -123,7 +129,7 @@ export default function RichContentCard(
|
||||
)
|
||||
: (
|
||||
<img
|
||||
src={proxyIfHttp(richContent.thumbnailUrl!)}
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -105,11 +105,11 @@ export function UserListPopover(
|
||||
}
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
globalThis.addEventListener("scroll", onScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
globalThis.removeEventListener("scroll", onScroll, true);
|
||||
};
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user