feat(downloads): gate library row actions by media_kind (E7 S5b)

Surface the S5a media_kind discriminator in the library UI. A generic
uploaded file (media_kind='file') is download+share only — no player and
no timeline to trim, so Play/Edit/Edit-details are media-only; it shows a
neutral file glyph instead of an (always-empty) thumbnail and no duration
chip (container/size still read). The "Shared with me" tab's file row is
gated the same way. Re-download is now hidden for edit clips and uploads
too (neither has a re-fetchable source — the backend already rejects both
with 409), removing a latent click-to-409 on edit rows.

FE-only: the serializer already emits media_kind (S5a). Adds media_kind to
the DownloadAsset type and "upload" to the job_kind union; DOM tests cover
the file-row presentation (glyph, no img, no duration).
This commit is contained in:
2026-08-29 18:38:21 +02:00
parent fe0b34cd00
commit b4ad59274b
5 changed files with 150 additions and 83 deletions
+100 -79
View File
@@ -266,59 +266,75 @@ export default function DownloadCenter() {
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => (
<DownloadRow key={job.id} job={job}>
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
{library.map((job) => {
// E7·S5b — gate row actions on the stored media_kind. A generic uploaded file
// (media_kind='file') is download+share only: no player and no timeline to trim
// (Play/Edit are media-only), and no metadata/channel edit. Re-download needs a
// re-fetchable source, which an edit clip and an upload both lack (backend rejects
// both), so it's a true-download-only action.
const isFile = job.asset?.media_kind === "file";
const isMedia = !isFile;
const canRedownload = job.job_kind !== "edit" && job.job_kind !== "upload";
return (
<DownloadRow key={job.id} job={job}>
{isMedia && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{saveBtn(job)}
{isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
{isMedia && (
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
{canRedownload && (
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
@@ -337,34 +353,39 @@ export default function DownloadCenter() {
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => (
<DownloadRow key={job.id} job={job}>
{job.can_download && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
{(sharedQ.data ?? []).map((job) => {
// Same media_kind gate as the library (S5b): a shared generic file is download-only —
// no player, no trim. Its owner set can_download, which still gates save/edit here.
const isMedia = job.asset?.media_kind !== "file";
return (
<DownloadRow key={job.id} job={job}>
{isMedia && job.can_download && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{job.can_download && saveBtn(job)}
{isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
)}
{job.can_download && saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
</DownloadRow>
);
})}
{sharedQ.data && sharedQ.data.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}