- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
thumbnail
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
import clsx from "clsx";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Scissors } from "lucide-react";
|
|
import type { LibraryFile } from "../lib/api";
|
|
import { navItemAttrs } from "../lib/listNav";
|
|
import { JobThumb } from "./downloadRow";
|
|
import { jobTitle } from "./downloadTitle";
|
|
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
|
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
|
|
import {
|
|
MEDIA_BADGE,
|
|
MEDIA_BAR,
|
|
MEDIA_HOVER_LAYER,
|
|
MEDIA_PICTURE,
|
|
mediaPictureBox,
|
|
mediaTitleClass,
|
|
} from "./ui/mediaCardStyles";
|
|
import { MoreButton } from "./ui/MoreButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
|
|
// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it (a file
|
|
// that is not media: opens it in the viewer), the title under it, then where it came from and what
|
|
// needs saying about it (kept, leaving soon). The
|
|
// right click, the long press and the "…" open the same menu as its table row (DESIGN.md "Files page":
|
|
// the grid is the list's other paint, a toggle remembered per folder).
|
|
|
|
export function FileCard({
|
|
file,
|
|
index,
|
|
onOpen,
|
|
menuProps,
|
|
moreProps,
|
|
menuOpen,
|
|
arrived,
|
|
}: {
|
|
file: LibraryFile;
|
|
/** Its place on the page, for the keyboard walk. */
|
|
index: number;
|
|
/** Plays it, or opens it in the viewer. Null: a shared file its owner keeps from me. */
|
|
onOpen: (() => void) | null;
|
|
menuProps: Record<string, unknown>;
|
|
moreProps: Record<string, unknown>;
|
|
menuOpen: boolean;
|
|
/** The file a link or a notice opened the page on. */
|
|
arrived: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const title = jobTitle(file);
|
|
const channel = file.display_uploader || file.asset?.uploader || null;
|
|
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
|
const media = file.asset?.media_kind !== "file";
|
|
const thumb = <JobThumb job={file} box="h-full w-full" />;
|
|
return (
|
|
<MediaCard
|
|
dense
|
|
rootProps={{ ...menuProps, ...navItemAttrs(index, title), "data-file-id": file.id }}
|
|
className={clsx(arrived && "ring-2 ring-accent/60")}
|
|
picture={
|
|
<div className={mediaPictureBox("wide")}>
|
|
{onOpen ? (
|
|
<Pressable
|
|
onClick={onOpen}
|
|
aria-label={t(media ? "downloads.actions.play" : "files.actions.view")}
|
|
data-nav-open=""
|
|
className={MEDIA_PICTURE}
|
|
>
|
|
{thumb}
|
|
{media && (
|
|
<div className={MEDIA_HOVER_LAYER}>
|
|
<MediaPlayGlyph small />
|
|
</div>
|
|
)}
|
|
</Pressable>
|
|
) : (
|
|
<div className={clsx(MEDIA_PICTURE, "cursor-default")}>{thumb}</div>
|
|
)}
|
|
{clip && (
|
|
<span
|
|
className={clsx(MEDIA_BADGE, "left-1.5 top-1.5 inline-flex items-center gap-0.5")}
|
|
>
|
|
<Scissors className="size-3" aria-hidden />
|
|
{t("downloads.clipBadge")}
|
|
</span>
|
|
)}
|
|
<span className={MEDIA_BAR}>
|
|
{/* `media`: legible on any picture, the Plex cards' hover row's look. */}
|
|
<MoreButton
|
|
variant="media"
|
|
label={t("files.rowMenu")}
|
|
expanded={menuOpen}
|
|
menuProps={moreProps}
|
|
/>
|
|
</span>
|
|
</div>
|
|
}
|
|
title={
|
|
<span className={mediaTitleClass(true)} title={title}>
|
|
{title}
|
|
</span>
|
|
}
|
|
meta={
|
|
<span className="flex flex-col gap-1">
|
|
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
|
<SourceBadge file={file} />
|
|
{channel && <span className="truncate">{channel}</span>}
|
|
</span>
|
|
<span className="flex flex-wrap items-center gap-1 empty:hidden">
|
|
<PhoneBadge file={file} />
|
|
<RetentionBadge file={file} quiet />
|
|
</span>
|
|
</span>
|
|
}
|
|
more={{ props: moreProps, open: menuOpen }}
|
|
moreLabel={t("files.rowMenu")}
|
|
/>
|
|
);
|
|
}
|