feat(media): delete videos from the media library
Add an admin flow to permanently delete a media asset and clean up everything it leaves behind. - Delete dialog with a destructive, irreversible confirmation; deletion is blocked while the video is still attached and shows the in-use lessons so the admin can detach first. - Usage list resolves lesson/course context for each attachment, links to the lesson editor via an anchor (right-click "open in new tab"), and exposes a refresh button to re-check usage after detaching elsewhere. - Storage cleanup runs as a background maintenance job: the producer captures the asset's object keys/prefixes up front and an idempotent worker sweeps HLS segments, thumbnails, audio, transcript, and the raw upload from object storage. Also harden usage accounting so stale attachments cannot trap an asset: - asset_usages has no FK to its polymorphic target, so prune a lesson's usage rows whenever the lesson is hard-deleted (single + bulk paths). - Usage resolution drops orphaned lesson usages (lesson gone or course soft-deleted) so they neither appear in the list nor block deletion. Document the icon-only button convention (variant="secondary") in AGENTS.md.
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
AssetUsage,
|
||||
AssetUsageGraph,
|
||||
CreateAssetRequest,
|
||||
DeleteAssetRequest,
|
||||
GetAssetTranscriptRequest,
|
||||
GetAssetUsageRequest,
|
||||
GetYouTubeMetadataRequest,
|
||||
@@ -273,6 +274,30 @@ export class MediaApi extends BaseApiWithErrors {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an asset and its stored files. The API blocks deletion
|
||||
* while the asset is still attached anywhere (409 ASSET_IN_USE). Returns true
|
||||
* only when the asset was actually removed.
|
||||
*/
|
||||
async deleteAsset(assetId: string): Promise<boolean> {
|
||||
let deleted = false;
|
||||
await this.execute<DeleteAssetRequest>({
|
||||
requestFn: () => classroomio.organization.assets[':assetId'].$delete({ param: { assetId } }),
|
||||
logContext: 'deleting media asset',
|
||||
onSuccess: () => {
|
||||
deleted = true;
|
||||
this.assets = this.assets.filter((asset) => asset.id !== assetId);
|
||||
snackbar.success('snackbar.media_manager.delete_success');
|
||||
},
|
||||
onError: (error) => {
|
||||
const inUse = typeof error === 'object' && error !== null && 'code' in error && error.code === 'ASSET_IN_USE';
|
||||
snackbar.error(inUse ? 'snackbar.media_manager.delete_in_use' : 'snackbar.media_manager.delete_failed');
|
||||
}
|
||||
});
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async getVideoPlaybackUrl(asset: OrganizationAsset): Promise<string | null> {
|
||||
return this.getAssetDownloadUrl(asset, { forPlayback: true });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import LinkIcon from '@lucide/svelte/icons/link';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
@@ -27,6 +28,7 @@
|
||||
onUsage?: (asset: OrganizationAsset) => void;
|
||||
onDownload?: (asset: OrganizationAsset) => void;
|
||||
onManageThumbnails?: (asset: OrganizationAsset) => void;
|
||||
onDelete?: (asset: OrganizationAsset) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +37,8 @@
|
||||
onEdit = () => {},
|
||||
onUsage = () => {},
|
||||
onDownload = () => {},
|
||||
onManageThumbnails = () => {}
|
||||
onManageThumbnails = () => {},
|
||||
onDelete = () => {}
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -87,6 +90,13 @@
|
||||
{$t('media_manager.actions.download')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => onDelete(asset)}>
|
||||
<span class="flex items-center gap-2">
|
||||
<Trash2Icon size={14} />
|
||||
{$t('media_manager.actions.delete')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
|
||||
import AssetUsageList from './asset-usage-list.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
formatBytes,
|
||||
formatUsageDate,
|
||||
getAssetDisplayName,
|
||||
getKindIcon,
|
||||
getTargetTypeLabel,
|
||||
type AssetUsageGraph,
|
||||
type OrganizationAsset
|
||||
} from '$features/media/utils';
|
||||
@@ -19,6 +24,7 @@
|
||||
selectedAsset?: OrganizationAsset | null;
|
||||
usageData?: AssetUsageGraph | null;
|
||||
isLoading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -27,6 +33,7 @@
|
||||
selectedAsset = null,
|
||||
usageData = null,
|
||||
isLoading = false,
|
||||
onRefresh = () => {},
|
||||
onOpenChange = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
@@ -96,42 +103,37 @@
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('media_manager.usage.loading')}</p>
|
||||
<Empty description={$t('media_manager.usage.loading')} icon={Spinner} class="ui:max-h-40 py-6" />
|
||||
{:else if isUsageEmpty}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('media_manager.usage.empty')}</p>
|
||||
{:else if usageData}
|
||||
<p class="ui:text-muted-foreground mb-3 text-sm">
|
||||
{$t('media_manager.usage.total')}
|
||||
{usageData.usageCount}
|
||||
</p>
|
||||
<div class="max-h-[420px] overflow-auto rounded-lg border">
|
||||
<div class="divide-y">
|
||||
{#each usageData.usages as usage (usage.id)}
|
||||
<div class="grid grid-cols-1 gap-2 p-3 text-sm md:grid-cols-5 md:items-center md:gap-4">
|
||||
<div>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.target_type')}</p>
|
||||
<p>{getTargetTypeLabel(usage.targetType)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.target_id')}</p>
|
||||
<p class="font-mono text-xs">{usage.targetId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.slot')}</p>
|
||||
<p>{usage.slotType}{usage.slotKey ? ` (${usage.slotKey})` : ''}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.position')}</p>
|
||||
<p>{usage.position ?? t.get('media_manager.common.not_available')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.added')}</p>
|
||||
<p>{formatUsageDate(usage.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('media_manager.usage.total')}
|
||||
{usageData.usageCount}
|
||||
</p>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onclick={() => onRefresh()}
|
||||
aria-label={$t('media_manager.usage.refresh')}
|
||||
>
|
||||
<RefreshCwIcon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4}>
|
||||
{$t('media_manager.usage.refresh')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
<AssetUsageList usages={usageData.usages} onNavigate={() => handleOpenChange(false)} />
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { formatUsageDate, getSlotTypeLabel, getTargetTypeLabel, type AssetUsageGraph } from '$features/media/utils';
|
||||
|
||||
interface Props {
|
||||
usages: AssetUsageGraph['usages'];
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
let { usages, onNavigate = () => {} }: Props = $props();
|
||||
|
||||
function lessonHref(courseId: string, lessonId: string) {
|
||||
return resolve(`/courses/${courseId}/lessons/${lessonId}`, {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-h-[420px] overflow-auto rounded-lg border">
|
||||
<div class="divide-y">
|
||||
{#each usages as usage (usage.id)}
|
||||
<div class="flex flex-col gap-2 p-3 text-sm md:flex-row md:items-center md:justify-between md:gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium">
|
||||
{usage.targetTitle ?? getTargetTypeLabel(usage.targetType)}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{#if usage.courseTitle}
|
||||
{usage.courseTitle} ·
|
||||
{/if}
|
||||
{getSlotTypeLabel(usage.slotType)}{usage.slotKey ? ` (${usage.slotKey})` : ''} ·
|
||||
{$t('media_manager.usage.table.added')}
|
||||
{formatUsageDate(usage.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if usage.targetType === 'lesson' && usage.courseId}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
href={lessonHref(usage.courseId!, usage.targetId)}
|
||||
onclick={() => onNavigate()}
|
||||
>
|
||||
<ExternalLinkIcon size={14} class="mr-1.5" />
|
||||
{$t('media_manager.usage.go_to_lesson')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
|
||||
|
||||
import AssetUsageList from './asset-usage-list.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getAssetDisplayName, type AssetUsageGraph, type OrganizationAsset } from '$features/media/utils';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
asset?: OrganizationAsset | null;
|
||||
usageData?: AssetUsageGraph | null;
|
||||
isLoadingUsage?: boolean;
|
||||
isDeleting?: boolean;
|
||||
onConfirm?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
asset = null,
|
||||
usageData = null,
|
||||
isLoadingUsage = false,
|
||||
isDeleting = false,
|
||||
onConfirm = () => {},
|
||||
onRefresh = () => {},
|
||||
onOpenChange = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
const isInUse = $derived((usageData?.usageCount ?? 0) > 0);
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
open = isOpen;
|
||||
if (!isOpen) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => handleOpenChange(isOpen)}>
|
||||
<Dialog.Content class="w-[95%] max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('media_manager.delete.title')}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if asset}
|
||||
<span class="font-medium">{getAssetDisplayName(asset)}</span>
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if isLoadingUsage}
|
||||
<Empty description={$t('media_manager.usage.loading')} icon={Spinner} class="ui:max-h-40 py-6" />
|
||||
{:else if isInUse}
|
||||
<Alert.Root variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<Alert.Title>{$t('media_manager.delete.in_use_title')}</Alert.Title>
|
||||
<Alert.Description>{$t('media_manager.delete.in_use_description')}</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
<div class="mt-2 mb-1 flex items-center justify-between gap-2">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('media_manager.usage.total')}
|
||||
{usageData?.usageCount}
|
||||
</p>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onclick={() => onRefresh()}
|
||||
aria-label={$t('media_manager.usage.refresh')}
|
||||
>
|
||||
<RefreshCwIcon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4}>
|
||||
{$t('media_manager.usage.refresh')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
<AssetUsageList usages={usageData?.usages ?? []} onNavigate={() => handleOpenChange(false)} />
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => handleOpenChange(false)}>
|
||||
{$t('media_manager.delete.close')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
<Alert.Title>{$t('media_manager.delete.irreversible_title')}</Alert.Title>
|
||||
<Alert.Description>{$t('media_manager.delete.irreversible_description')}</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" disabled={isDeleting} onclick={() => handleOpenChange(false)}>
|
||||
{$t('media_manager.delete.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={isDeleting} onclick={onConfirm}>
|
||||
{$t('media_manager.delete.confirm')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,5 +1,7 @@
|
||||
export { default as AssetCard } from './asset-card.svelte';
|
||||
export { default as AssetUsageDialog } from './asset-usage-dialog.svelte';
|
||||
export { default as AssetUsageList } from './asset-usage-list.svelte';
|
||||
export { default as DeleteAssetDialog } from './delete-asset-dialog.svelte';
|
||||
export { default as EditAssetDialog } from './edit-asset-dialog.svelte';
|
||||
export { default as ManageThumbnailsDialog } from './manage-thumbnails-dialog.svelte';
|
||||
export { default as MediaFilters } from './media-filters.svelte';
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
AssetCard,
|
||||
AssetUsageDialog,
|
||||
DeleteAssetDialog,
|
||||
EditAssetDialog,
|
||||
ManageThumbnailsDialog,
|
||||
MediaFilters,
|
||||
@@ -36,8 +37,10 @@
|
||||
let editOpen = $state(false);
|
||||
let usageOpen = $state(false);
|
||||
let manageThumbsOpen = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
let isSavingAsset = $state(false);
|
||||
let isUsageLoading = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let downloadingAssetId = $state<string | null>(null);
|
||||
let selectedAsset = $state<OrganizationAsset | null>(null);
|
||||
let usageData = $state<AssetUsageGraph | null>(null);
|
||||
@@ -96,11 +99,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openUsage(asset: OrganizationAsset) {
|
||||
selectedAsset = asset;
|
||||
usageOpen = true;
|
||||
async function loadUsage(asset: OrganizationAsset) {
|
||||
isUsageLoading = true;
|
||||
usageData = null;
|
||||
try {
|
||||
usageData = await mediaApi.getAssetUsage(asset.id);
|
||||
} finally {
|
||||
@@ -108,12 +108,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openUsage(asset: OrganizationAsset) {
|
||||
selectedAsset = asset;
|
||||
usageOpen = true;
|
||||
usageData = null;
|
||||
await loadUsage(asset);
|
||||
}
|
||||
|
||||
async function refreshUsage() {
|
||||
if (!selectedAsset) return;
|
||||
|
||||
await loadUsage(selectedAsset);
|
||||
}
|
||||
|
||||
function resetUsageModalState() {
|
||||
selectedAsset = null;
|
||||
usageData = null;
|
||||
isUsageLoading = false;
|
||||
}
|
||||
|
||||
async function openDelete(asset: OrganizationAsset) {
|
||||
selectedAsset = asset;
|
||||
deleteOpen = true;
|
||||
usageData = null;
|
||||
await loadUsage(asset);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!selectedAsset) return;
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
const deleted = await mediaApi.deleteAsset(selectedAsset.id);
|
||||
if (!deleted) return;
|
||||
|
||||
deleteOpen = false;
|
||||
await refreshStorageSummary();
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
resetUsageModalState();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAsset(asset: OrganizationAsset) {
|
||||
downloadingAssetId = asset.id;
|
||||
try {
|
||||
@@ -168,6 +209,7 @@
|
||||
onUsage={openUsage}
|
||||
onDownload={downloadAsset}
|
||||
onManageThumbnails={openManageThumbnails}
|
||||
onDelete={openDelete}
|
||||
/>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
@@ -208,5 +250,17 @@
|
||||
{selectedAsset}
|
||||
{usageData}
|
||||
isLoading={isUsageLoading}
|
||||
onRefresh={refreshUsage}
|
||||
onOpenChange={handleUsageOpenChange}
|
||||
/>
|
||||
|
||||
<DeleteAssetDialog
|
||||
bind:open={deleteOpen}
|
||||
asset={selectedAsset}
|
||||
{usageData}
|
||||
isLoadingUsage={isUsageLoading}
|
||||
{isDeleting}
|
||||
onConfirm={confirmDelete}
|
||||
onRefresh={refreshUsage}
|
||||
onOpenChange={handleDeleteOpenChange}
|
||||
/>
|
||||
|
||||
@@ -110,6 +110,14 @@ export function getTargetTypeLabel(targetType: string): string {
|
||||
return t.get(keyMap[targetType] ?? 'media_manager.usage.target.unknown');
|
||||
}
|
||||
|
||||
export function getSlotTypeLabel(slotType: string): string {
|
||||
const keyMap: Record<string, string> = {
|
||||
lesson_video: 'media_manager.usage.slot_type.lesson_video',
|
||||
lesson_document: 'media_manager.usage.slot_type.lesson_document'
|
||||
};
|
||||
return t.get(keyMap[slotType] ?? 'media_manager.usage.slot_type.unknown');
|
||||
}
|
||||
|
||||
export function formatUsageDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return t.get('media_manager.common.not_available');
|
||||
|
||||
Reference in New Issue
Block a user