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:
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user