feat: preview templates, thumbnail features, 2 new org templates
This commit is contained in:
@@ -11,6 +11,8 @@ import type {
|
||||
GetYouTubeMetadataRequest,
|
||||
ListAssetsRequest,
|
||||
OrganizationAsset,
|
||||
RegenerateAssetThumbnailRequest,
|
||||
SelectAssetThumbnailRequest,
|
||||
UpdateAssetData,
|
||||
UpdateAssetRequest,
|
||||
YouTubeMetadata
|
||||
@@ -404,6 +406,67 @@ export class MediaApi extends BaseApiWithErrors {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Persist the chosen thumbnail URL (must be a generated candidate or a fresh upload). */
|
||||
async selectThumbnail(assetId: string, thumbnailUrl: string): Promise<OrganizationAsset | null> {
|
||||
let updated: OrganizationAsset | null = null;
|
||||
await this.execute<SelectAssetThumbnailRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.assets[':assetId'].thumbnail.$put({
|
||||
param: { assetId },
|
||||
json: { thumbnailUrl }
|
||||
}),
|
||||
logContext: 'selecting asset thumbnail',
|
||||
onSuccess: (response) => {
|
||||
updated = response.data;
|
||||
this.assets = this.assets.map((asset) => (asset.id === response.data.id ? response.data : asset));
|
||||
snackbar.success('snackbar.media_manager.thumbnail_updated');
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.media_manager.thumbnail_update_failed');
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Enqueue a fresh generate-thumbnail job and return the media_job id. */
|
||||
async regenerateThumbnails(assetId: string): Promise<string | null> {
|
||||
let jobId: string | null = null;
|
||||
await this.execute<RegenerateAssetThumbnailRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.jobs.media.asset[':assetId']['regenerate-thumbnail'].$post({
|
||||
param: { assetId }
|
||||
}),
|
||||
logContext: 'regenerating asset thumbnail',
|
||||
onSuccess: (response) => {
|
||||
jobId = response.data.id;
|
||||
snackbar.success('snackbar.media_manager.thumbnail_regen_started');
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.media_manager.thumbnail_regen_failed');
|
||||
}
|
||||
});
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
async refreshAsset(assetId: string): Promise<OrganizationAsset | null> {
|
||||
let updated: OrganizationAsset | null = null;
|
||||
await this.execute<(typeof classroomio.organization.assets)[':assetId']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.assets[':assetId'].$get({
|
||||
param: { assetId }
|
||||
}),
|
||||
logContext: 'refreshing asset',
|
||||
onSuccess: (response) => {
|
||||
updated = response.data;
|
||||
this.assets = this.assets.map((asset) => (asset.id === response.data.id ? response.data : asset));
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Enqueue extract-audio + Whisper for an existing upload. */
|
||||
async generateTranscript(assetId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
import type { TAssetUpdate } from '@cio/utils/validation/assets';
|
||||
|
||||
import { AssetCard, AssetUsageDialog, EditAssetDialog, MediaFilters, StorageCards } from '$features/media/components';
|
||||
import {
|
||||
AssetCard,
|
||||
AssetUsageDialog,
|
||||
EditAssetDialog,
|
||||
ManageThumbnailsDialog,
|
||||
MediaFilters,
|
||||
StorageCards
|
||||
} from '$features/media/components';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import type { AssetKindFilter, AssetStatusFilter, AssetUsageGraph, OrganizationAsset } from '$features/media/utils';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
@@ -28,6 +35,7 @@
|
||||
let isRefreshing = $state(false);
|
||||
let editOpen = $state(false);
|
||||
let usageOpen = $state(false);
|
||||
let manageThumbsOpen = $state(false);
|
||||
let isSavingAsset = $state(false);
|
||||
let isUsageLoading = $state(false);
|
||||
let downloadingAssetId = $state<string | null>(null);
|
||||
@@ -66,6 +74,11 @@
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
function openManageThumbnails(asset: OrganizationAsset) {
|
||||
selectedAsset = asset;
|
||||
manageThumbsOpen = true;
|
||||
}
|
||||
|
||||
async function saveAsset(fields: TAssetUpdate) {
|
||||
if (!selectedAsset) return;
|
||||
|
||||
@@ -148,7 +161,14 @@
|
||||
{:else}
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each assets as asset (asset.id)}
|
||||
<AssetCard {asset} {downloadingAssetId} onEdit={openEditAsset} onUsage={openUsage} onDownload={downloadAsset} />
|
||||
<AssetCard
|
||||
{asset}
|
||||
{downloadingAssetId}
|
||||
onEdit={openEditAsset}
|
||||
onUsage={openUsage}
|
||||
onDownload={downloadAsset}
|
||||
onManageThumbnails={openManageThumbnails}
|
||||
/>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
{/if}
|
||||
@@ -181,6 +201,8 @@
|
||||
|
||||
<EditAssetDialog bind:open={editOpen} asset={selectedAsset} isSaving={isSavingAsset} onSave={saveAsset} />
|
||||
|
||||
<ManageThumbnailsDialog bind:open={manageThumbsOpen} asset={selectedAsset} />
|
||||
|
||||
<AssetUsageDialog
|
||||
bind:open={usageOpen}
|
||||
{selectedAsset}
|
||||
|
||||
@@ -19,6 +19,42 @@ export function formatBytes(bytes: number | null | undefined): string {
|
||||
return `${size.toFixed(size >= 100 || exponent === 0 ? 0 : 2)} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For values that already render in GB or TB, return the equivalent in the
|
||||
* next-smaller unit so users keep a feel for the scale (e.g. `1.50 GB` →
|
||||
* `≈ 1,536 MB`). Returns `null` for sub-GB sizes where no extra context is
|
||||
* helpful.
|
||||
*/
|
||||
export function formatBytesSecondary(bytes: number | null | undefined): string | null {
|
||||
if (bytes == null || Number.isNaN(bytes) || bytes <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const KB = 1024;
|
||||
const MB = KB * 1024;
|
||||
const GB = MB * 1024;
|
||||
const TB = GB * 1024;
|
||||
|
||||
if (bytes >= TB) {
|
||||
const inGb = bytes / GB;
|
||||
return `≈ ${formatNumber(inGb, inGb >= 100 ? 0 : 2)} GB`;
|
||||
}
|
||||
|
||||
if (bytes >= GB) {
|
||||
const inMb = bytes / MB;
|
||||
return `≈ ${formatNumber(inMb, inMb >= 100 ? 0 : 2)} MB`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatNumber(value: number, fractionDigits: number): string {
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits
|
||||
});
|
||||
}
|
||||
|
||||
export function mapAssetProviderToLessonVideoType(provider: string): LessonVideoType {
|
||||
if (provider === 'youtube') return 'youtube';
|
||||
if (provider === 'google_drive') return 'google_drive';
|
||||
|
||||
@@ -57,5 +57,14 @@ export type GetAssetTranscriptResponse = InferResponseType<GetAssetTranscriptReq
|
||||
export type GetAssetTranscriptSuccess = Extract<GetAssetTranscriptResponse, { success: true }>;
|
||||
export type AssetTranscriptPayload = GetAssetTranscriptSuccess['data'];
|
||||
|
||||
export type SelectAssetThumbnailRequest = (typeof classroomio.organization)['assets'][':assetId']['thumbnail']['$put'];
|
||||
export type SelectAssetThumbnailResponse = InferResponseType<SelectAssetThumbnailRequest>;
|
||||
export type SelectAssetThumbnailSuccess = Extract<SelectAssetThumbnailResponse, { success: true }>;
|
||||
|
||||
export type RegenerateAssetThumbnailRequest =
|
||||
(typeof classroomio.jobs.media.asset)[':assetId']['regenerate-thumbnail']['$post'];
|
||||
export type RegenerateAssetThumbnailResponse = InferResponseType<RegenerateAssetThumbnailRequest>;
|
||||
export type RegenerateAssetThumbnailSuccess = Extract<RegenerateAssetThumbnailResponse, { success: true }>;
|
||||
|
||||
export type AssetKindFilter = 'all' | 'video' | 'document' | 'image' | 'audio' | 'other';
|
||||
export type AssetStatusFilter = 'all' | 'active' | 'archived';
|
||||
|
||||
Reference in New Issue
Block a user