feat: preview templates, thumbnail features, 2 new org templates

This commit is contained in:
Rotimi Best
2026-05-23 12:24:57 +01:00
parent 4fdd4115b9
commit 78ab13f54b
106 changed files with 8320 additions and 2040 deletions
@@ -7,6 +7,9 @@
import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ImageIcon from '@lucide/svelte/icons/image';
import LinkIcon from '@lucide/svelte/icons/link';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { t } from '$lib/utils/functions/translations';
import {
@@ -23,6 +26,7 @@
onEdit?: (asset: OrganizationAsset) => void;
onUsage?: (asset: OrganizationAsset) => void;
onDownload?: (asset: OrganizationAsset) => void;
onManageThumbnails?: (asset: OrganizationAsset) => void;
}
let {
@@ -30,13 +34,14 @@
downloadingAssetId = null,
onEdit = () => {},
onUsage = () => {},
onDownload = () => {}
onDownload = () => {},
onManageThumbnails = () => {}
}: Props = $props();
</script>
<Item.Root
variant="outline"
class="group relative max-w-[300px] cursor-pointer p-4!"
class="group relative w-full! max-w-75 cursor-pointer p-4!"
onclick={() => onEdit(asset)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -57,10 +62,24 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" onclick={(event) => event.stopPropagation()}>
<DropdownMenu.Item onclick={() => onEdit(asset)}>
{$t('media_manager.actions.edit')}
<span class="flex items-center gap-2">
<PencilIcon size={14} />
{$t('media_manager.actions.edit')}
</span>
</DropdownMenu.Item>
{#if asset.kind === 'video'}
<DropdownMenu.Item onclick={() => onManageThumbnails(asset)}>
<span class="flex items-center gap-2">
<ImageIcon size={14} />
{$t('media_manager.actions.manage_thumbnails')}
</span>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onclick={() => onUsage(asset)}>
{$t('media_manager.actions.where_used')}
<span class="flex items-center gap-2">
<LinkIcon size={14} />
{$t('media_manager.actions.where_used')}
</span>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onDownload(asset)} disabled={downloadingAssetId === asset.id}>
<span class="flex items-center gap-2">
@@ -1,5 +1,6 @@
export { default as AssetCard } from './asset-card.svelte';
export { default as AssetUsageDialog } from './asset-usage-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';
export { default as StorageCards } from './storage-cards.svelte';
@@ -0,0 +1,284 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
import { Skeleton } from '@cio/ui/base/skeleton';
import CheckIcon from '@lucide/svelte/icons/check';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { mediaApi } from '$features/media/api';
import type { OrganizationAsset } from '$features/media/utils';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
interface Props {
open?: boolean;
asset?: OrganizationAsset | null;
onThumbnailSaved?: (asset: OrganizationAsset) => void;
}
const THUMBNAIL_MAX_SIZE = 5 * FileDropZone.MEGABYTE;
const REGEN_POLL_INTERVAL_MS = 2000;
const REGEN_POLL_TIMEOUT_MS = 60_000;
const SLOT_KEYS = ['start', 'middle', 'end'] as const;
type Slot = (typeof SLOT_KEYS)[number];
let { open = $bindable(false), asset = null, onThumbnailSaved }: Props = $props();
let selectedUrl = $state<string | null>(null);
let customUrl = $state<string | null>(null);
let videoUrl = $state<string | null>(null);
let isVideoLoading = $state(false);
let isUploading = $state(false);
let isSaving = $state(false);
let isRegenerating = $state(false);
const candidates = $derived((asset?.thumbnailCandidates ?? []) as string[]);
const currentThumbnail = $derived(asset?.thumbnailUrl ?? null);
const hasSelection = $derived(Boolean(selectedUrl) && selectedUrl !== currentThumbnail);
const allCandidatesMissing = $derived(candidates.length === 0 && !isRegenerating);
function slotForIndex(index: number): Slot {
return SLOT_KEYS[Math.min(index, SLOT_KEYS.length - 1)];
}
$effect(() => {
if (!open || !asset) return;
selectedUrl = asset.thumbnailUrl ?? candidates[0] ?? null;
customUrl = null;
void loadVideoPreview(asset);
});
async function loadVideoPreview(target: OrganizationAsset) {
if (target.kind !== 'video') {
videoUrl = null;
return;
}
isVideoLoading = true;
try {
videoUrl = await mediaApi.getVideoPlaybackUrl(target);
} finally {
isVideoLoading = false;
}
}
function resetState() {
selectedUrl = null;
customUrl = null;
videoUrl = null;
isUploading = false;
isSaving = false;
isRegenerating = false;
}
function selectCandidate(url: string) {
selectedUrl = url;
}
async function handleCustomUpload(files: File[]) {
const file = files[0];
if (!file) return;
isUploading = true;
try {
const url = await uploadImage(file);
customUrl = url;
selectedUrl = url;
snackbar.success('snackbar.media_manager.thumbnail_uploaded');
} catch {
snackbar.error('snackbar.media_manager.thumbnail_upload_failed');
} finally {
isUploading = false;
}
}
function handleUploadRejected({ reason }: { reason: FileRejectedReason }) {
if (reason === 'Maximum file size exceeded') {
snackbar.error('snackbar.media_manager.thumbnail_too_large');
return;
}
snackbar.error('snackbar.media_manager.thumbnail_invalid');
}
async function pollUntilRegenComplete(assetId: string) {
const deadline = Date.now() + REGEN_POLL_TIMEOUT_MS;
const initialCandidates = candidates.slice().sort().join('|');
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, REGEN_POLL_INTERVAL_MS));
const next = await mediaApi.refreshAsset(assetId);
if (!next) continue;
const nextCandidates = ((next.thumbnailCandidates ?? []) as string[]).slice().sort().join('|');
if (nextCandidates && nextCandidates !== initialCandidates) {
asset = next;
selectedUrl = next.thumbnailUrl ?? ((next.thumbnailCandidates ?? [])[0] as string | undefined) ?? null;
return;
}
}
}
async function regenerate() {
if (!asset || isRegenerating) return;
isRegenerating = true;
try {
const jobId = await mediaApi.regenerateThumbnails(asset.id);
if (!jobId) return;
await pollUntilRegenComplete(asset.id);
} finally {
isRegenerating = false;
}
}
async function save() {
if (!asset || !selectedUrl || !hasSelection) return;
isSaving = true;
try {
const updated = await mediaApi.selectThumbnail(asset.id, selectedUrl);
if (!updated) return;
asset = updated;
onThumbnailSaved?.(updated);
open = false;
} finally {
isSaving = false;
}
}
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) resetState();
}}
>
<Dialog.Content class="max-h-[85vh] w-[95%] max-w-3xl overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{$t('media_manager.thumbnails.manage_title')}</Dialog.Title>
<Dialog.Description>{$t('media_manager.thumbnails.manage_description')}</Dialog.Description>
</Dialog.Header>
{#if asset}
<div class="space-y-5 p-1">
<div class="ui:bg-muted/40 aspect-video w-full overflow-hidden rounded-md border">
{#if isVideoLoading}
<Skeleton class="h-full w-full" />
{:else if videoUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video controls preload="metadata" src={videoUrl} class="h-full w-full bg-black"></video>
{:else}
<div class="ui:text-muted-foreground flex h-full w-full items-center justify-center text-sm">
{$t('media_manager.thumbnails.preview_unavailable')}
</div>
{/if}
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium">{$t('media_manager.thumbnails.generated')}</p>
{#if allCandidatesMissing}
<p class="ui:text-muted-foreground text-xs">
{$t('media_manager.thumbnails.none_generated_warning')}
</p>
{/if}
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
{#each SLOT_KEYS as slotKey, index (slotKey)}
{@const url = candidates[index] ?? null}
{@const isSelected = url !== null && url === selectedUrl}
{#if isRegenerating}
<Skeleton class="aspect-video w-full rounded-md" />
{:else if url}
<button
type="button"
onclick={() => selectCandidate(url)}
class={`ui:focus-visible:ring-ring relative aspect-video w-full overflow-hidden rounded-md border text-left transition focus-visible:ring-2 focus-visible:outline-none ${
isSelected ? 'ring-primary border-primary ring-2' : 'hover:border-primary/50'
}`}
aria-pressed={isSelected}
>
<img
src={url}
alt={$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
class="h-full w-full object-cover"
/>
<span
class="ui:bg-background/85 absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
>
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
</span>
{#if isSelected}
<span
class="ui:bg-primary ui:text-primary-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full"
>
<CheckIcon class="size-3" />
</span>
{/if}
</button>
{:else}
<div
class="ui:bg-muted/30 ui:text-muted-foreground flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
>
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
</div>
{/if}
{/each}
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{$t('media_manager.thumbnails.upload_custom')}</p>
<div class={isUploading ? 'ui:pointer-events-none ui:opacity-50' : ''}>
<FileDropZone.Root
accept={FileDropZone.ACCEPT_IMAGE}
maxFiles={1}
fileCount={0}
maxFileSize={THUMBNAIL_MAX_SIZE}
onUpload={handleCustomUpload}
onFileRejected={handleUploadRejected}
>
<FileDropZone.Trigger
label={$t('media_manager.form.thumbnail_drop_label')}
formatMaxSize={() => $t('media_manager.form.thumbnail_drop_size')}
/>
</FileDropZone.Root>
</div>
{#if customUrl}
<div class="flex items-center gap-3 rounded-md border p-2">
<img
src={customUrl}
alt={$t('media_manager.thumbnails.custom_preview_alt')}
class="h-16 w-24 rounded object-cover"
/>
<p class="ui:text-muted-foreground text-xs">
{$t('media_manager.thumbnails.custom_selected_hint')}
</p>
</div>
{/if}
</div>
</div>
{/if}
<Dialog.Footer class="ui:gap-2 flex-col sm:flex-row sm:justify-between">
<Button variant="outline" onclick={regenerate} loading={isRegenerating} disabled={isSaving}>
<RefreshCcwIcon class="size-4" />
{$t('media_manager.thumbnails.regenerate')}
</Button>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)} disabled={isSaving || isRegenerating}>
{$t('media_manager.edit.cancel')}
</Button>
<Button onclick={save} loading={isSaving} disabled={!hasSelection || isRegenerating || isUploading}>
{$t('media_manager.thumbnails.save_selection')}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -4,7 +4,7 @@
import Link2Icon from '@lucide/svelte/icons/link-2';
import { ActivityCard } from '$features/ui';
import { formatBytes } from '$features/media/utils';
import { formatBytes, formatBytesSecondary } from '$features/media/utils';
import type { AssetStorageSummary } from '$features/media/utils';
import { t } from '$lib/utils/functions/translations';
@@ -14,19 +14,23 @@
let { storageSummary }: Props = $props();
function bytesDescription(bytes: number | null | undefined, fallbackKey: string): string {
return formatBytesSecondary(bytes) ?? t.get(fallbackKey);
}
const storageCards = $derived([
{
icon: FileIcon,
title: t.get('media_manager.storage.total_bytes'),
percentage: formatBytes(storageSummary?.totalBytes ?? 0),
description: t.get('media_manager.storage.total_bytes'),
description: bytesDescription(storageSummary?.totalBytes, 'media_manager.storage.total_bytes'),
hidePercentage: true
},
{
icon: FileVideoIcon,
title: t.get('media_manager.storage.internal_bytes'),
percentage: formatBytes(storageSummary?.internalBytes ?? 0),
description: t.get('media_manager.storage.internal_bytes'),
description: bytesDescription(storageSummary?.internalBytes, 'media_manager.storage.internal_bytes'),
hidePercentage: true
},
{