Files
siftlode/frontend/src/features/downloads/downloadRow.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

159 lines
6.1 KiB
TypeScript

import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
import { DOWNLOAD_PHASES } from "../../lib/downloadQueue";
import { api, type DownloadJob } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
import { MediaProgress } from "../../components/ui/MediaCard";
import RemoteImage from "../../components/ui/RemoteImage";
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
// details dialog the icon button.
const STATUS_CLS: Record<string, string> = {
queued: "text-muted",
running: "text-accent",
paused: "text-warn",
done: "text-success",
error: "text-danger",
canceled: "text-muted",
};
export function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams.
if (job.status === "running" && job.phase && DOWNLOAD_PHASES.includes(job.phase)) {
return (
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
return (
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${job.status}`)}
</span>
);
}
// A picture's box when the caller gives none: a thumbnail-sized 16:9 frame.
const THUMB_BOX =
"w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */
// An image larger than this keeps the file glyph: a row's thumbnail is the whole file, and a page of
// camera originals would pull hundreds of megabytes for pictures a few rem wide.
const IMAGE_THUMB_MAX_BYTES = 8 * 1024 * 1024;
/** An image file's own picture for its thumbnail, or null (not an image, not readable, too big). */
function imageThumbOf(job: DownloadJob): string | null {
const a = job.asset;
if (a?.preview !== "image" || !job.can_download) return null;
if ((a.size_bytes ?? Infinity) > IMAGE_THUMB_MAX_BYTES) return null;
return api.previewUrl(job.id);
}
export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
const remote = job.asset?.thumbnail_url ?? null;
// `job.poster_url` only SIGNALS a poster exists; the <img> src is api.posterImageUrl.
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
// a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer
// (gone since, or not an image after all), not a hiccup: the glyph at once, no retries.
if (job.asset?.media_kind === "file") {
const glyph = <File className="w-8 h-8" aria-hidden="true" />;
return (
<div className={`${box} flex items-center justify-center text-muted`}>
<RemoteImage
src={imageThumbOf(job)}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
retries={0}
fallback={glyph}
/>
</div>
);
}
// How far into it the viewer is, the media cards' bar.
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
// No picture at all (a link that failed before its details, a poster that never came): a quiet
// glyph of what it is, like the file glyph above — an empty box read as "still loading".
const glyph = (
<div className="flex h-full items-center justify-center text-muted">
{job.asset?.media_kind === "audio" ? (
<Music className="w-7 h-7" aria-hidden="true" />
) : (
<Film className="w-7 h-7" aria-hidden="true" />
)}
</div>
);
// The source's picture, the poster's fallback: retried like any remote picture, then the glyph.
const remotePicture = (
<RemoteImage
src={remote}
alt=""
loading="lazy"
className="w-full h-full object-cover"
fallback={glyph}
/>
);
return (
<div className={`${box} relative`}>
<MediaProgress pct={pct} />
{poster ? (
// Our own poster failing is an answer (the file is missing), so straight to the source's
// picture. RemoteImage remembers the failure by URL, so a recycled row never shows one job's
// fallback over the next one's poster, and forgets it once the row moves on — a poster that
// 404'd while its file was still being written is asked for again when the row comes back.
<RemoteImage
src={poster}
alt=""
loading="lazy"
className="w-full h-full object-cover"
retries={0}
fallback={remotePicture}
/>
) : (
remotePicture
)}
</div>
);
}
export function IconBtn({
onClick,
title,
danger,
open,
children,
}: {
onClick: () => void;
title: string;
danger?: boolean;
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
open?: boolean;
children: ReactNode;
}) {
return (
<IconButton
variant={danger ? "danger" : "ghost"}
size="md"
onClick={onClick}
data-nav-open={open ? "" : undefined}
label={title}
>
{children}
</IconButton>
);
}