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
@@ -718,6 +718,31 @@ export class LessonApi extends BaseApiWithErrors {
this.isDirty = true;
}
/**
* Updates the thumbnail URL on a lesson video's metadata so the card preview
* reflects the asset's new thumbnail without waiting for a reload.
*/
updateLessonVideoThumbnail(videoIndex: number, thumbnailUrl: string) {
if (!this.lesson) return;
const videos = Array.isArray(this.lesson.videos) ? [...this.lesson.videos] : [];
const target = videos[videoIndex];
if (!target) return;
const metadata =
target.metadata && typeof target.metadata === 'object' && !Array.isArray(target.metadata)
? (target.metadata as Record<string, unknown>)
: {};
videos[videoIndex] = {
...target,
metadata: { ...metadata, thumbnailUrl }
};
this.lesson = { ...this.lesson, videos };
this.isDirty = true;
}
/**
* Deletes a document from lesson materials
*/
@@ -3,7 +3,7 @@
import { Image } from '$features/ui';
import VideoIcon from '@lucide/svelte/icons/video';
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
import { courseApi } from '$features/course/api';
import { courseApi, lessonApi } from '$features/course/api';
import VideoCardDropdown from './video-card-dropdown.svelte';
import {
getVideoThumbnailUrl,
@@ -138,7 +138,12 @@
{/if}
</div>
{#if isEditMode}
<VideoCardDropdown {video} {onRemove} menuPlacement="inline" />
<VideoCardDropdown
{video}
{onRemove}
onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)}
menuPlacement="inline"
/>
{/if}
</div>
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
@@ -1,8 +1,14 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CaptionsIcon from '@lucide/svelte/icons/captions';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import EyeIcon from '@lucide/svelte/icons/eye';
import ImageIcon from '@lucide/svelte/icons/image';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { IconButton } from '@cio/ui/custom/icon-button';
import { mediaApi } from '$features/media/api';
import { ManageThumbnailsDialog } from '$features/media/components';
import type { OrganizationAsset } from '$features/media/utils';
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
import { sidePanel } from '$features/side-panel';
import { snackbar } from '$features/ui/snackbar/store';
@@ -15,11 +21,16 @@
interface Props {
video: LessonVideo;
onRemove: () => void;
onThumbnailSaved?: (thumbnailUrl: string) => void;
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
menuPlacement?: 'inline' | 'corner';
}
let { video, onRemove, menuPlacement = 'inline' }: Props = $props();
let { video, onRemove, onThumbnailSaved, menuPlacement = 'inline' }: Props = $props();
let manageThumbsOpen = $state(false);
let manageThumbsAsset = $state<OrganizationAsset | null>(null);
let isLoadingAsset = $state(false);
let isTranscribing = $state(false);
let hasTranscript = $state(false);
@@ -29,6 +40,7 @@
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
const canManageThumbnails = $derived(video.type === 'upload' && !!assetId);
onDestroy(() => {
activePoller?.stop();
@@ -116,6 +128,31 @@
}
}
async function handleManageThumbnails() {
if (!assetId || isLoadingAsset) return;
isLoadingAsset = true;
try {
const asset = await mediaApi.refreshAsset(assetId);
if (!asset) {
snackbar.error('snackbar.media_manager.list_failed');
return;
}
manageThumbsAsset = asset;
manageThumbsOpen = true;
} finally {
isLoadingAsset = false;
}
}
function handleThumbnailSaved(asset: OrganizationAsset) {
manageThumbsAsset = asset;
if (asset.thumbnailUrl) {
onThumbnailSaved?.(asset.thumbnailUrl);
}
}
const generateLabel = $derived(
isTranscribing
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
@@ -142,6 +179,19 @@
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if canManageThumbnails}
<DropdownMenu.Item
disabled={isLoadingAsset}
onclick={() => {
void handleManageThumbnails();
}}
>
<span class="flex items-center gap-2">
<ImageIcon size={14} />
{$t('media_manager.actions.manage_thumbnails')}
</span>
</DropdownMenu.Item>
{/if}
{#if canGenerateTranscript}
<DropdownMenu.Item
disabled={isTranscribing}
@@ -149,7 +199,10 @@
void handleGenerateTranscript();
}}
>
{generateLabel}
<span class="flex items-center gap-2">
<CaptionsIcon size={14} />
{generateLabel}
</span>
</DropdownMenu.Item>
{#if hasTranscript}
<DropdownMenu.Item
@@ -157,12 +210,24 @@
void handleViewTranscript();
}}
>
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
<span class="flex items-center gap-2">
<EyeIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
</span>
</DropdownMenu.Item>
{/if}
{/if}
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
<span class="flex items-center gap-2">
<Trash2Icon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ManageThumbnailsDialog
bind:open={manageThumbsOpen}
asset={manageThumbsAsset}
onThumbnailSaved={handleThumbnailSaved}
/>
@@ -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';
@@ -8,6 +8,9 @@ import {
CorporateLandingHero,
CorporateLandingNav,
CorporateLandingPage,
EditorialLandingHero,
EditorialLandingNav,
EditorialLandingPage,
MinimalLandingHero,
MinimalLandingNav,
MinimalLandingPage,
@@ -22,7 +25,10 @@ import {
TechLandingPage,
TerminalLandingHero,
TerminalLandingNav,
TerminalLandingPage
TerminalLandingPage,
VibrantLandingHero,
VibrantLandingNav,
VibrantLandingPage
} from '@cio/ui/custom/org-landing-page';
export const landingPageThemeComponents = {
@@ -33,7 +39,9 @@ export const landingPageThemeComponents = {
tech: TechLandingPage,
studio: StudioLandingPage,
corporate: CorporateLandingPage,
terminal: TerminalLandingPage
terminal: TerminalLandingPage,
editorial: EditorialLandingPage,
vibrant: VibrantLandingPage
} as const;
export const landingPageNavComponents = {
@@ -44,7 +52,9 @@ export const landingPageNavComponents = {
tech: TechLandingNav,
studio: StudioLandingNav,
corporate: CorporateLandingNav,
terminal: TerminalLandingNav
terminal: TerminalLandingNav,
editorial: EditorialLandingNav,
vibrant: VibrantLandingNav
} as const;
export const landingPageHeroComponents = {
@@ -55,5 +65,7 @@ export const landingPageHeroComponents = {
tech: TechLandingHero,
studio: StudioLandingHero,
corporate: CorporateLandingHero,
terminal: TerminalLandingHero
terminal: TerminalLandingHero,
editorial: EditorialLandingHero,
vibrant: VibrantLandingHero
} as const;
@@ -27,7 +27,9 @@ export const landingPageThemes = [
'tech',
'studio',
'corporate',
'terminal'
'terminal',
'editorial',
'vibrant'
] as const satisfies OrgLandingPageTheme[];
export const defaultLandingPageHero: OrgLandingPageHero = {
@@ -724,7 +726,154 @@ export function importThemeComponent(theme: LandingPageThemeKey) {
return import('@cio/ui/custom/org-landing-page/corporate.svelte');
case 'terminal':
return import('@cio/ui/custom/org-landing-page/terminal.svelte');
case 'editorial':
return import('@cio/ui/custom/org-landing-page/editorial.svelte');
case 'vibrant':
return import('@cio/ui/custom/org-landing-page/vibrant.svelte');
default:
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
}
}
const NAV_SNIPPET_THEMES: ReadonlySet<LandingPageThemeKey> = new Set([
'minimal',
'saas',
'tech',
'studio',
'corporate',
'terminal',
'editorial',
'vibrant'
]);
export function themeRendersNavInsideHero(theme: LandingPageThemeKey): boolean {
return NAV_SNIPPET_THEMES.has(theme);
}
export function themeHeaderShellClass(_theme: LandingPageThemeKey): string {
return 'ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans';
}
export { themeStyle } from '@cio/ui/custom/org-landing-page/theme-style';
export async function importThemeCourseCard(theme: LandingPageThemeKey) {
switch (theme) {
case 'bold':
return (await import('@cio/ui/custom/org-landing-page/bold/course-card.svelte')).default;
case 'classic':
return (await import('@cio/ui/custom/org-landing-page/classic/course-card.svelte')).default;
case 'saas':
return (await import('@cio/ui/custom/org-landing-page/saas/course-card.svelte')).default;
case 'tech':
return (await import('@cio/ui/custom/org-landing-page/tech/course-card.svelte')).default;
case 'studio':
return (await import('@cio/ui/custom/org-landing-page/studio/course-card.svelte')).default;
case 'corporate':
return (await import('@cio/ui/custom/org-landing-page/corporate/course-card.svelte')).default;
case 'terminal':
return (await import('@cio/ui/custom/org-landing-page/terminal/course-card.svelte')).default;
case 'editorial':
return (await import('@cio/ui/custom/org-landing-page/editorial/course-card.svelte')).default;
case 'vibrant':
return (await import('@cio/ui/custom/org-landing-page/vibrant/course-card.svelte')).default;
default:
return (await import('@cio/ui/custom/org-landing-page/minimal/course-card.svelte')).default;
}
}
export function themeCourseGridClass(theme: LandingPageThemeKey): string {
switch (theme) {
case 'corporate':
case 'tech':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]';
case 'saas':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-px ui:bg-[var(--landing-border)] ui:border ui:border-[var(--landing-border)]';
case 'minimal':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6';
case 'studio':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3';
case 'terminal':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]';
case 'editorial':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-4';
case 'vibrant':
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5';
default:
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6';
}
}
export async function importThemeNavHero(theme: LandingPageThemeKey) {
switch (theme) {
case 'bold': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/bold/nav.svelte'),
import('@cio/ui/custom/org-landing-page/bold/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'classic': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/classic/nav.svelte'),
import('@cio/ui/custom/org-landing-page/classic/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'saas': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/saas/nav.svelte'),
import('@cio/ui/custom/org-landing-page/saas/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'tech': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/tech/nav.svelte'),
import('@cio/ui/custom/org-landing-page/tech/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'studio': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/studio/nav.svelte'),
import('@cio/ui/custom/org-landing-page/studio/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'corporate': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/corporate/nav.svelte'),
import('@cio/ui/custom/org-landing-page/corporate/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'terminal': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/terminal/nav.svelte'),
import('@cio/ui/custom/org-landing-page/terminal/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'editorial': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/editorial/nav.svelte'),
import('@cio/ui/custom/org-landing-page/editorial/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
case 'vibrant': {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/vibrant/nav.svelte'),
import('@cio/ui/custom/org-landing-page/vibrant/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
default: {
const [nav, hero] = await Promise.all([
import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'),
import('@cio/ui/custom/org-landing-page/minimal/hero.svelte')
]);
return { NavComponent: nav.default, HeroComponent: hero.default };
}
}
}
@@ -0,0 +1,70 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import XIcon from '@lucide/svelte/icons/x';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { mockOrgLandingPageProps } from '@cio/ui/custom/org-landing-page';
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
import { landingPageThemes } from '$features/org/utils/landing-page';
type LandingPageTheme = (typeof landingPageThemes)[number];
const validThemes = new Set<LandingPageTheme>(landingPageThemes);
const previewTheme = $derived.by<LandingPageTheme | null>(() => {
const value = page.url.searchParams.get('preview');
return value && validThemes.has(value as LandingPageTheme) ? (value as LandingPageTheme) : null;
});
const open = $derived(previewTheme !== null);
const ThemeComponent = $derived(
previewTheme ? (landingPageThemeComponents[previewTheme] ?? landingPageThemeComponents.minimal) : null
);
const previewProps = $derived({
...mockOrgLandingPageProps,
embed: undefined
});
function closePreview() {
const nextUrl = new URL(page.url);
nextUrl.searchParams.delete('preview');
goto(`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, {
replaceState: false,
keepFocus: true,
noScroll: true
});
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && open) {
closePreview();
}
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content
showCloseButton={false}
class="ui:w-[calc(100vw-2rem)] ui:h-[calc(100vh-2rem)] ui:max-w-[calc(100vw-2rem)] ui:max-h-[calc(100vh-2rem)] ui:overflow-hidden ui:p-0 ui:gap-0 ui:sm:max-w-[calc(100vw-2rem)]"
>
<Dialog.Title class="ui:sr-only">Theme preview</Dialog.Title>
<Dialog.Description class="ui:sr-only"
>Preview the selected landing page theme with sample content.</Dialog.Description
>
<div class="absolute top-3 right-3 z-[9999]">
<IconButton variant="secondary" aria-label="Close preview" onclick={closePreview}>
<XIcon size={16} />
</IconButton>
</div>
<div class="h-full w-full overflow-y-auto">
{#if ThemeComponent}
<ThemeComponent {...previewProps} disableCourseLinks={true} />
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -2,7 +2,10 @@
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import EyeIcon from '@lucide/svelte/icons/eye';
import LayoutTemplateIcon from '@lucide/svelte/icons/layout-template';
import PaintbrushIcon from '@lucide/svelte/icons/paintbrush';
import ZapIcon from '@lucide/svelte/icons/zap';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
@@ -10,12 +13,19 @@
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
import { basePath } from '$lib/utils/store/app';
import type { AccountOrg } from '$features/app/types';
import { orgApi } from '$features/org/api/org.svelte';
import { snackbar } from '$features/ui/snackbar/store';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import * as Card from '@cio/ui/base/card';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { IconButton } from '@cio/ui/custom/icon-button';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import ThemePreviewDialog from '$features/settings/components/theme-preview-dialog.svelte';
import {
createDefaultLandingPageSettings,
landingPageThemes,
@@ -86,6 +96,18 @@
preview: '/templates/terminal.png',
titleKey: 'settings.landing_page.theme.cards.terminal.title',
descriptionKey: 'settings.landing_page.theme.cards.terminal.description'
},
{
value: 'editorial',
preview: '/templates/editorial.png',
titleKey: 'settings.landing_page.theme.cards.editorial.title',
descriptionKey: 'settings.landing_page.theme.cards.editorial.description'
},
{
value: 'vibrant',
preview: '/templates/vibrant.png',
titleKey: 'settings.landing_page.theme.cards.vibrant.title',
descriptionKey: 'settings.landing_page.theme.cards.vibrant.description'
}
] as const satisfies ReadonlyArray<{
value: LandingPageTheme;
@@ -125,6 +147,37 @@
goto(resolve(`${$currentOrgPath}/settings/landingpage/edit?theme=${theme}`, {}));
}
async function handleApplyTheme(theme: LandingPageTheme) {
if (!$currentOrg.id) return;
if ($isFreePlan && isPaidTheme(theme)) {
openUpgradeModal();
return;
}
const updatedLandingPage = normalizeLandingPageSettings({
...normalized,
theme
});
await orgApi.update($currentOrg.id, {
landingpage: updatedLandingPage as AccountOrg['landingpage']
});
normalized = updatedLandingPage;
snackbar.success('snackbar.success_update');
}
function handlePreviewTheme(theme: LandingPageTheme) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set('preview', theme);
goto(`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, {
replaceState: false,
keepFocus: true,
noScroll: true
});
}
const navCount = $derived(normalized.navItems?.length ?? 0);
const heroHeading = $derived(normalized.hero?.heading?.trim() || t.get('settings.landing_page.current.hero_empty'));
const navCountLabel = $derived(
@@ -227,12 +280,31 @@
<div class="ui:border-border bg-background relative overflow-hidden rounded-xl border">
{#if isLocked}
<div
class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm"
class="absolute top-2 left-2 z-10 flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm"
>
<PremiumIcon size={14} color="white" />
{$t('settings.landing_page.gallery.upgrade')}
</div>
{/if}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="absolute top-2 right-2 z-10">
<IconButton variant="secondary" aria-label={$t('settings.landing_page.gallery.card_menu_label')}>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => handlePreviewTheme(themeCard.value)}>
<EyeIcon size={16} class="mr-2" />
{$t('settings.landing_page.gallery.preview')}
</DropdownMenu.Item>
{#if !isLocked}
<DropdownMenu.Item onclick={() => handleApplyTheme(themeCard.value)}>
<PaintbrushIcon size={16} class="mr-2" />
{$t('settings.landing_page.gallery.apply_theme')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
<div class="bg-background aspect-[4/3] w-full overflow-hidden">
<img
src={themeCard.preview}
@@ -257,3 +329,5 @@
</div>
</section>
</div>
<ThemePreviewDialog />
@@ -18,7 +18,13 @@
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
import { getLectureNo } from '$features/course/utils/functions';
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import {
importThemeNavHero,
normalizeLandingPageSettings,
themeHeaderShellClass,
themeStyle,
themeRendersNavInsideHero
} from '$features/org/utils/landing-page';
import { currentOrg } from '$lib/utils/store/org';
import { basePath } from '$lib/utils/store/app';
import { user } from '$lib/utils/store/user';
@@ -57,11 +63,9 @@
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
const heroShellClass = $derived(
landingSettings.theme === 'classic'
? 'ui:w-full ui:bg-muted/10 ui:text-foreground ui:font-sans'
: 'ui:w-full ui:bg-background ui:text-foreground ui:font-sans'
);
const heroShellClass = $derived(`ui:w-full ${themeHeaderShellClass(landingSettings.theme)}`);
const heroShellStyle = $derived(themeStyle(landingSettings.theme));
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
@@ -115,21 +119,10 @@
const theme = landingSettings.theme;
let cancelled = false;
void Promise.all([
theme === 'bold'
? import('@cio/ui/custom/org-landing-page/bold/nav.svelte')
: theme === 'classic'
? import('@cio/ui/custom/org-landing-page/classic/nav.svelte')
: import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'),
theme === 'bold'
? import('@cio/ui/custom/org-landing-page/bold/hero.svelte')
: theme === 'classic'
? import('@cio/ui/custom/org-landing-page/classic/hero.svelte')
: import('@cio/ui/custom/org-landing-page/minimal/hero.svelte')
]).then(([navMod, heroMod]) => {
void importThemeNavHero(theme).then(({ NavComponent: nav, HeroComponent: hero }) => {
if (!cancelled) {
NavComponent = navMod.default;
HeroComponent = heroMod.default;
NavComponent = nav;
HeroComponent = hero;
}
});
@@ -202,10 +195,10 @@
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
{#if showMarketingHero}
<div class={heroShellClass}>
<div class={heroShellClass} style={heroShellStyle}>
{#if NavComponent && HeroComponent}
{#if landingSettings.theme === 'minimal'}
<HeroComponent hero={courseHero}>
{#if navInsideHero}
<HeroComponent hero={courseHero} orgName={orgNameForNav}>
{#snippet navigation()}
<NavComponent
orgName={orgNameForNav}
@@ -222,7 +215,7 @@
navItems={landingSettings.navItems}
{authAction}
/>
<HeroComponent hero={courseHero} />
<HeroComponent hero={courseHero} orgName={orgNameForNav} />
{/if}
{/if}
@@ -11,6 +11,7 @@
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import AppSetup from './app-setup.svelte';
import VisitOrgSiteBtn from '$features/ui/visit-org-site-btn.svelte';
const siteName = $derived($currentOrg.siteName);
@@ -36,6 +37,8 @@
<span class="grow"></span>
<AppSetup />
<VisitOrgSiteBtn variant="outline" labelKey="dashboard.open_academy" />
<Search />
<Popover.Root>
@@ -1,13 +1,6 @@
import { QUESTION_TYPE_KEY, QUESTION_TYPE_REGISTRY, type QuestionTypeKey } from '@cio/question-types';
/** Question types that require a paid plan. On free plan, these show a premium icon and open upgrade modal on click. */
export const PREMIUM_QUESTION_TYPE_KEYS = new Set<QuestionTypeKey>([
QUESTION_TYPE_KEY.FILE_UPLOAD,
QUESTION_TYPE_KEY.ORDERING,
QUESTION_TYPE_KEY.LINK,
QUESTION_TYPE_KEY.STAR,
QUESTION_TYPE_KEY.VIDEO_RECORDING
]);
export { PREMIUM_QUESTION_TYPE_KEYS } from '@cio/question-types';
const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
[QUESTION_TYPE_KEY.RADIO]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.single',
@@ -1,7 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { Button, type ButtonVariant } from '@cio/ui/base/button';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import SquareArrowOutUpRight from '@lucide/svelte/icons/square-arrow-out-up-right';
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
@@ -45,13 +46,15 @@
});
</script>
<Button {href} target="_blank" {variant} class={`ml-2 ${className}`.trim()}>
<Button {href} target="_blank" {variant} class={`&_span:hidden! md:&_span:block ml-2 ${className}`.trim()}>
{#if !$isMobile}
{#if isLMS}
{$t('dashboard.visit_site')}
{:else}
{$t(labelKey)}
{/if}
<span>
{#if isLMS}
{$t('dashboard.visit_site')}
{:else}
{$t(labelKey)}
{/if}
</span>
{/if}
<ArrowUpRightIcon class="custom" />
<SquareArrowOutUpRight class="custom" />
</Button>