feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -0,0 +1,149 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CreateWidgetInput,
CreateWidgetRequest,
DeleteWidgetRequest,
GetWidgetDetailRequest,
GetWidgetsRequest,
PreviewWidgetInput,
PreviewWidgetRequest,
PublishWidgetRequest,
RollbackWidgetRequest,
UpdateWidgetInput,
UpdateWidgetRequest,
WidgetDetail,
WidgetListItem
} from '../utils/types';
import { ZCreateWidget, ZUpdateWidget } from '@cio/utils/validation/widget';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
class WidgetApi extends BaseApiWithErrors {
widgets = $state<WidgetListItem[]>([]);
widgetDetail = $state<WidgetDetail | null>(null);
async getWidgets() {
return this.execute<GetWidgetsRequest>({
requestFn: () => classroomio.organization.widgets.$get(),
logContext: 'fetching widgets',
onSuccess: (response) => {
this.widgets = response.data;
}
});
}
async getWidget(widgetId: string) {
return this.execute<GetWidgetDetailRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].$get({
param: { widgetId }
}),
logContext: 'fetching widget detail',
onSuccess: (response) => {
this.widgetDetail = response.data;
}
});
}
async createWidget(fields: CreateWidgetInput) {
const result = ZCreateWidget.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return null;
}
const response = await this.execute<CreateWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets.$post({
json: result.data
}),
logContext: 'creating widget',
onSuccess: (response) => {
this.widgets = [response.data, ...this.widgets];
snackbar.success('widgets.notifications.created');
}
});
return response?.data ?? null;
}
async updateWidget(widgetId: string, fields: UpdateWidgetInput, silent = false) {
const result = ZUpdateWidget.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return null;
}
const response = await this.execute<UpdateWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].$put({
param: { widgetId },
json: result.data
}),
logContext: 'updating widget',
onSuccess: (response) => {
if (!silent) snackbar.success('widgets.notifications.saved');
this.widgets = this.widgets.map((widget) => (widget.id === widgetId ? response.data : widget));
}
});
return response?.data ?? null;
}
async previewWidget(widgetId: string, fields: PreviewWidgetInput) {
const response = await this.execute<PreviewWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].preview.$post({
param: { widgetId },
json: fields
}),
logContext: 'previewing widget'
});
return response?.data ?? null;
}
async publishWidget(widgetId: string) {
return this.execute<PublishWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].publish.$post({
param: { widgetId }
}),
logContext: 'publishing widget',
onSuccess: () => {
snackbar.success('widgets.notifications.published');
}
});
}
async rollbackWidget(widgetId: string, versionId: string) {
return this.execute<RollbackWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].rollback.$post({
param: { widgetId },
json: { versionId }
}),
logContext: 'rolling back widget',
onSuccess: () => {
snackbar.success('widgets.notifications.rolled_back');
}
});
}
async deleteWidget(widgetId: string) {
return this.execute<DeleteWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].$delete({
param: { widgetId }
}),
logContext: 'deleting widget',
onSuccess: () => {
this.widgets = this.widgets.filter((widget) => widget.id !== widgetId);
snackbar.success('widgets.notifications.deleted');
}
});
}
}
export const widgetApi = new WidgetApi();
@@ -0,0 +1,150 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import { Checkbox } from '@cio/ui/base/checkbox';
import { t } from '$lib/utils/functions/translations';
import FilterIcon from '@lucide/svelte/icons/filter';
import type { FilterPublished } from '../utils/widget-editor-utils';
import type { WidgetDetail } from '../utils/types';
interface Props {
activeFilterCount: number;
filterPublished: FilterPublished;
filterCourseType: string;
filterTagSlugs: string[];
availableTags: WidgetDetail['availableTags'];
courseTypeOptions: { value: string; label: string }[];
onClearAll: () => void;
onFilterPublishedChange: (value: FilterPublished) => void;
onFilterCourseTypeChange: (value: string) => void;
onFilterTagSlugsChange: (slugs: string[]) => void;
}
let {
activeFilterCount,
filterPublished,
filterCourseType,
filterTagSlugs,
availableTags,
courseTypeOptions,
onClearAll,
onFilterPublishedChange,
onFilterCourseTypeChange,
onFilterTagSlugsChange
}: Props = $props();
</script>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
type="button"
class="ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted focus-visible:ui:ring-primary/40 relative inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-sm shadow-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
aria-label={$t('courses.tag_filters.filter')}
>
<FilterIcon class="size-3.5 shrink-0" />
<span class="hidden text-xs sm:inline">{$t('courses.tag_filters.filter')}</span>
{#if activeFilterCount > 0}
<span
class="ui:bg-foreground ui:text-background inline-flex min-w-[18px] items-center justify-center rounded-full px-1 py-0.5 text-[10px] leading-none font-semibold"
>
{activeFilterCount}
</span>
{/if}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content
align="end"
class="ui:bg-popover ui:text-popover-foreground ui:border-border w-72 border p-0 shadow-lg"
>
<div class="ui:border-border flex items-center justify-between border-b px-4 py-3">
<p class="text-sm font-semibold">{$t('courses.tag_filters.popover_title')}</p>
<button
type="button"
class="ui:text-muted-foreground ui:hover:text-foreground text-xs disabled:pointer-events-none disabled:opacity-40"
disabled={activeFilterCount === 0}
onclick={onClearAll}
>
{$t('courses.tag_filters.clear_all')}
</button>
</div>
<div class="space-y-4 px-4 py-3">
<!-- Status -->
<div class="space-y-2">
<p class="ui:text-muted-foreground text-[10px] font-semibold tracking-wide uppercase">
{$t('widgets.filter.status')}
</p>
<div class="flex flex-wrap gap-1.5">
{#each [['all', $t('widgets.filter.all')], ['published', $t('widgets.status.published')], ['unpublished', $t('widgets.filter.unpublished')]] as [val, label] (val)}
<button
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors {filterPublished === val
? 'ui:border-foreground ui:bg-foreground ui:text-background'
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'}"
onclick={() => onFilterPublishedChange(val as FilterPublished)}
>
{label}
</button>
{/each}
</div>
</div>
<!-- Course type -->
<div class="space-y-2">
<p class="ui:text-muted-foreground text-[10px] font-semibold tracking-wide uppercase">
{$t('widgets.filter.course_type')}
</p>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors {filterCourseType === 'all'
? 'ui:border-foreground ui:bg-foreground ui:text-background'
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'}"
onclick={() => onFilterCourseTypeChange('all')}
>
{$t('widgets.filter.all')}
</button>
{#each courseTypeOptions as opt (opt.value)}
<button
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors {filterCourseType === opt.value
? 'ui:border-foreground ui:bg-foreground ui:text-background'
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'}"
onclick={() => onFilterCourseTypeChange(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
</div>
<!-- Tags -->
{#if availableTags.length > 0}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-[10px] font-semibold tracking-wide uppercase">
{$t('courses.tag_filters.tags')}
</p>
<div class="max-h-48 space-y-1 overflow-y-auto">
{#each availableTags as tag (tag.id)}
<label class="ui:hover:bg-muted flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1.5">
<Checkbox
checked={filterTagSlugs.includes(tag.slug)}
onCheckedChange={(checked) => {
onFilterTagSlugsChange(
checked ? [...filterTagSlugs, tag.slug] : filterTagSlugs.filter((s) => s !== tag.slug)
);
}}
/>
<span class="inline-block h-2 w-2 shrink-0 rounded-full" style="background-color: {tag.color}"></span>
<span class="text-xs">{tag.name}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,242 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import type { WidgetDetail } from '../utils/types';
import type { WidgetConfig } from '../utils/types';
interface Props {
draftLayoutType: WidgetDetail['widget']['layoutType'];
draftConfig: WidgetConfig;
featuredCourseOptions: { id: string; title: string }[];
tagOptions: WidgetDetail['availableTags'];
onToggleCategoryTag: (tagId: string, checked: boolean) => void;
}
let {
draftLayoutType,
draftConfig = $bindable(),
featuredCourseOptions,
tagOptions,
onToggleCategoryTag
}: Props = $props();
</script>
{#if draftLayoutType === 'card_grid'}
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.columns')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.layoutOptions.cardGrid.columns}>
<Select.Trigger>{draftConfig.layoutOptions.cardGrid.columns}</Select.Trigger>
<Select.Content>
<Select.Item value="2">2</Select.Item>
<Select.Item value="3">3</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<InputField
type="number"
label={$t('widgets.layoutOptions.max_count')}
bind:value={draftConfig.layoutOptions.cardGrid.maxCount}
/>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.cardGrid.showRating} />
<Field.Label>{$t('widgets.layoutOptions.show_rating')}</Field.Label>
</Field.Field>
{:else if draftLayoutType === 'tag_filter'}
<InputField
type="number"
label={$t('widgets.layoutOptions.max_count')}
bind:value={draftConfig.layoutOptions.tagFilter.maxCount}
/>
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.tag_source')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.layoutOptions.tagFilter.tagSource}>
<Select.Trigger>
{draftConfig.layoutOptions.tagFilter.tagSource === 'auto'
? $t('widgets.layoutOptions.tag_source_auto')
: $t('widgets.layoutOptions.tag_source_manual')}
</Select.Trigger>
<Select.Content>
<Select.Item value="auto">{$t('widgets.layoutOptions.tag_source_auto')}</Select.Item>
<Select.Item value="manual">{$t('widgets.layoutOptions.tag_source_manual')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.tagFilter.showAllOption} />
<Field.Label>{$t('widgets.layoutOptions.show_all_option')}</Field.Label>
</Field.Field>
{:else if draftLayoutType === 'carousel'}
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.visible_cards')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.layoutOptions.carousel.visibleCards}>
<Select.Trigger>{draftConfig.layoutOptions.carousel.visibleCards}</Select.Trigger>
<Select.Content>
<Select.Item value="2">2</Select.Item>
<Select.Item value="3">3</Select.Item>
<Select.Item value="4">4</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<InputField
type="number"
label={$t('widgets.layoutOptions.max_count')}
bind:value={draftConfig.layoutOptions.carousel.maxCount}
/>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.carousel.autoPlay} />
<Field.Label>{$t('widgets.layoutOptions.auto_play')}</Field.Label>
</Field.Field>
{#if draftConfig.layoutOptions.carousel.autoPlay}
<InputField
type="number"
label={$t('widgets.layoutOptions.auto_play_interval')}
bind:value={draftConfig.layoutOptions.carousel.autoPlayIntervalMs}
/>
{/if}
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.carousel.showDots} />
<Field.Label>{$t('widgets.layoutOptions.show_dots')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.carousel.showArrows} />
<Field.Label>{$t('widgets.layoutOptions.show_arrows')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.carousel.loop} />
<Field.Label>{$t('widgets.layoutOptions.loop')}</Field.Label>
</Field.Field>
{:else if draftLayoutType === 'primary_course'}
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.featured_course')}</Field.Label>
<Select.Root
type="single"
value={draftConfig.layoutOptions.primaryCourse.featuredCourseId ?? ''}
onValueChange={(value) => {
draftConfig.layoutOptions.primaryCourse.featuredCourseId = value || null;
}}
>
<Select.Trigger>
{featuredCourseOptions.find((c) => c.id === draftConfig.layoutOptions.primaryCourse.featuredCourseId)?.title ??
$t('widgets.layoutOptions.none')}
</Select.Trigger>
<Select.Content>
{#each featuredCourseOptions as course (course.id)}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<InputField
label={$t('widgets.layoutOptions.eyebrow_label')}
bind:value={draftConfig.layoutOptions.primaryCourse.eyebrowLabel}
/>
<InputField
label={$t('widgets.layoutOptions.cta_label')}
bind:value={draftConfig.layoutOptions.primaryCourse.ctaLabel}
/>
<InputField
type="number"
label={$t('widgets.layoutOptions.secondary_max_count')}
bind:value={draftConfig.layoutOptions.primaryCourse.secondaryMaxCount}
/>
{:else if draftLayoutType === 'compact_list'}
<InputField
type="number"
label={$t('widgets.layoutOptions.max_count')}
bind:value={draftConfig.layoutOptions.compactList.maxCount}
/>
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.density')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.layoutOptions.compactList.density}>
<Select.Trigger>
{draftConfig.layoutOptions.compactList.density === 'comfortable'
? $t('widgets.layoutOptions.density_comfortable')
: $t('widgets.layoutOptions.density_compact')}
</Select.Trigger>
<Select.Content>
<Select.Item value="comfortable">{$t('widgets.layoutOptions.density_comfortable')}</Select.Item>
<Select.Item value="compact">{$t('widgets.layoutOptions.density_compact')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.compactList.showThumbnail} />
<Field.Label>{$t('widgets.layoutOptions.show_thumbnail')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.compactList.showTags} />
<Field.Label>{$t('widgets.layoutOptions.show_tags')}</Field.Label>
</Field.Field>
{:else if draftLayoutType === 'editorial_spotlight'}
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.main_course')}</Field.Label>
<Select.Root
type="single"
value={draftConfig.layoutOptions.editorialSpotlight.mainCourseId ?? ''}
onValueChange={(value) => {
draftConfig.layoutOptions.editorialSpotlight.mainCourseId = value || null;
}}
>
<Select.Trigger>
{featuredCourseOptions.find((c) => c.id === draftConfig.layoutOptions.editorialSpotlight.mainCourseId)?.title ??
$t('widgets.layoutOptions.none')}
</Select.Trigger>
<Select.Content>
{#each featuredCourseOptions as course (course.id)}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<InputField
type="number"
label={$t('widgets.layoutOptions.secondary_max_count')}
bind:value={draftConfig.layoutOptions.editorialSpotlight.secondaryMaxCount}
/>
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.title_style')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.layoutOptions.editorialSpotlight.titleStyle}>
<Select.Trigger>
{draftConfig.layoutOptions.editorialSpotlight.titleStyle === 'serif'
? $t('widgets.layoutOptions.title_style_serif')
: $t('widgets.layoutOptions.title_style_sans')}
</Select.Trigger>
<Select.Content>
<Select.Item value="serif">{$t('widgets.layoutOptions.title_style_serif')}</Select.Item>
<Select.Item value="sans">{$t('widgets.layoutOptions.title_style_sans')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
{:else if draftLayoutType === 'category_shelf'}
<Field.Field>
<Field.Label>{$t('widgets.layoutOptions.category_tags')}</Field.Label>
<div class="flex flex-wrap gap-1.5">
{#each tagOptions as tag (tag.id)}
{@const checked = draftConfig.layoutOptions.categoryShelf.categoryTagIds.includes(tag.id)}
<button
type="button"
class={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
checked
? 'border-emerald-500 bg-emerald-50 text-emerald-900 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200'
: 'ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted'
}`}
onclick={() => onToggleCategoryTag(tag.id, !checked)}
>
{tag.name}
</button>
{/each}
</div>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.layoutOptions.categoryShelf.showAllTab} />
<Field.Label>{$t('widgets.layoutOptions.show_all_tab')}</Field.Label>
</Field.Field>
<InputField
type="number"
label={$t('widgets.layoutOptions.max_per_category')}
bind:value={draftConfig.layoutOptions.categoryShelf.maxPerCategory}
/>
{/if}
@@ -0,0 +1,74 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Dialog from '@cio/ui/base/dialog';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button';
import { widgetApi } from '../api/widget.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
id: string;
name: string;
}
let { id, name }: Props = $props();
let renameOpen = $state(false);
let draftName = $state(name);
async function handleRename() {
await widgetApi.updateWidget(id, { name: draftName });
renameOpen = false;
}
async function handleDelete() {
await widgetApi.deleteWidget(id);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-4 right-4 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={(e) => {
e.stopPropagation();
draftName = name;
renameOpen = true;
}}
>
{$t('widgets.actions.rename')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600"
onclick={(e) => {
e.stopPropagation();
handleDelete();
}}
>
{$t('widgets.actions.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Dialog.Root bind:open={renameOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('widgets.actions.rename')}</Dialog.Title>
</Dialog.Header>
<InputField label={$t('widgets.form.name')} bind:value={draftName} />
<Dialog.Footer>
<Button variant="outline" onclick={() => (renameOpen = false)}>{$t('app.cancel')}</Button>
<Button onclick={handleRename}>{$t('app.save')}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,114 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { currentOrgPath } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { InputField } from '@cio/ui/custom/input-field';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PencilIcon from '@lucide/svelte/icons/pencil';
interface Props {
draftName: string;
status: string;
isPaidPlan: boolean;
isDirty: boolean;
layoutValidationError: string | null;
editingName: boolean;
onSave: () => void;
onPublish: () => void;
onDiscard: () => void;
onRenameStart: () => void;
onRenameFinish: () => void;
}
let {
draftName = $bindable(),
status,
isPaidPlan,
isDirty,
layoutValidationError,
editingName,
onSave,
onPublish,
onDiscard,
onRenameStart,
onRenameFinish
}: Props = $props();
</script>
<header
class="ui:border-border ui:bg-background ui:text-foreground flex shrink-0 items-center justify-between gap-3 border-b px-3 py-3"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
variant="ghost"
size="icon"
href={resolve(`${$currentOrgPath}/widgets`, {})}
class="ui:size-8 shrink-0"
aria-label={$t('widgets.actions.back')}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
{#if editingName}
<div class="flex max-w-md items-center gap-2">
<InputField
label={$t('widgets.form.name')}
bind:value={draftName}
className="min-w-[12rem]"
labelClassName="sr-only"
autoFocus
/>
<Button variant="secondary" size="sm" type="button" onclick={onRenameFinish}>
{$t('widgets.editor.finish_editing_name')}
</Button>
</div>
{:else}
<div class="flex min-w-0 items-center gap-1.5">
<h1 class="truncate text-sm tracking-tight sm:text-base">{draftName}</h1>
<Button
variant="ghost"
size="icon-sm"
type="button"
onclick={onRenameStart}
class="shrink-0"
aria-label={$t('widgets.editor.rename_widget')}
>
<PencilIcon class="size-3.5" />
</Button>
</div>
{/if}
<div class="flex flex-wrap items-center gap-1">
<Badge variant="secondary" class="text-[10px] uppercase">
{$t(`widgets.status.${status.toLowerCase()}`)}
</Badge>
<Badge variant="outline" class="text-[10px]">
{isPaidPlan ? $t('widgets.plan.paid') : $t('widgets.plan.free')}
</Badge>
</div>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
{#if isDirty}
<Button variant="ghost" size="sm" type="button" onclick={onDiscard} class="hidden sm:inline-flex">
{$t('widgets.editor.discard_short')}
</Button>
{/if}
<Button
variant="outline"
size="sm"
type="button"
disabled={!isDirty || Boolean(layoutValidationError)}
onclick={onSave}
>
{$t('widgets.editor.save_changes')}
</Button>
<Button size="sm" type="button" disabled={Boolean(layoutValidationError)} onclick={onPublish}>
{$t('widgets.actions.publish')}
</Button>
</div>
</header>
@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { isValidIframeMessage } from '../utils/preview-protocol';
import type { WidgetPreviewParentMessage } from '../utils/preview-protocol';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import { t } from '$lib/utils/functions/translations';
let {
payload = null,
maxWidth = undefined,
onSynced
}: {
payload: TWidgetPayload | null;
maxWidth?: number;
onSynced?: () => void;
} = $props();
let iframeElement = $state<HTMLIFrameElement | null>(null);
let iframeReady = $state(false);
let iframeError = $state('');
let iframeHeight = $state(400);
let readyRetryCount = $state(0);
const MAX_READY_RETRIES = 3;
const READY_TIMEOUT_MS = 5000;
const RETRY_BACKOFF = [1000, 2000, 4000];
let readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
let pendingPayload: TWidgetPayload | null = null;
/** postMessage uses the structured clone algorithm; Svelte proxies / non-plain objects must be plain JSON first. */
function clonePayloadForPostMessage(widgetPayload: TWidgetPayload): TWidgetPayload {
return JSON.parse(JSON.stringify(widgetPayload)) as TWidgetPayload;
}
function postToIframe(message: WidgetPreviewParentMessage) {
if (!iframeElement?.contentWindow) {
return;
}
const targetOrigin = window.location.origin;
iframeElement.contentWindow.postMessage(message, targetOrigin);
}
function sendPayload(widgetPayload: TWidgetPayload) {
if (!iframeReady) {
pendingPayload = widgetPayload;
return;
}
const requestId = crypto.randomUUID();
postToIframe({
type: 'WIDGET_PREVIEW_RENDER',
payload: clonePayloadForPostMessage(widgetPayload),
requestId
});
}
function handleIframeMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) {
return;
}
if (!isValidIframeMessage(event.data)) {
return;
}
const message = event.data;
if (message.type === 'WIDGET_PREVIEW_READY') {
iframeReady = true;
iframeError = '';
readyRetryCount = 0;
if (readyTimeoutId) {
clearTimeout(readyTimeoutId);
readyTimeoutId = null;
}
if (pendingPayload) {
sendPayload(pendingPayload);
pendingPayload = null;
}
return;
}
if (message.type === 'WIDGET_PREVIEW_RENDERED') {
onSynced?.();
return;
}
if (message.type === 'WIDGET_PREVIEW_ERROR') {
console.error('[WidgetPreview]', message.error);
iframeError = message.error;
return;
}
if (message.type === 'WIDGET_PREVIEW_RESIZE') {
iframeHeight = Math.max(200, message.height + 16);
}
}
function waitForReady() {
if (iframeReady || readyRetryCount >= MAX_READY_RETRIES) {
if (!iframeReady) {
iframeError = t.get('widgets.preview.load_failed');
}
return;
}
const backoffMs = RETRY_BACKOFF[readyRetryCount] ?? READY_TIMEOUT_MS;
readyTimeoutId = setTimeout(() => {
if (iframeReady) {
return;
}
readyRetryCount += 1;
postToIframe({ type: 'WIDGET_PREVIEW_PING' });
waitForReady();
}, backoffMs);
}
$effect(() => {
if (payload) {
sendPayload(payload);
}
});
onMount(() => {
window.addEventListener('message', handleIframeMessage);
waitForReady();
});
onDestroy(() => {
window.removeEventListener('message', handleIframeMessage);
if (readyTimeoutId) {
clearTimeout(readyTimeoutId);
}
});
</script>
{#if iframeError}
<div class="ui:text-muted-foreground flex items-center justify-center rounded-2xl border border-dashed p-8 text-sm">
<p>{iframeError}</p>
</div>
{:else}
<div class="overflow-hidden rounded-2xl transition-all" style={maxWidth ? `max-width:${maxWidth}px` : ''}>
<iframe
bind:this={iframeElement}
src="/widget-preview"
title={$t('widgets.preview.heading')}
sandbox="allow-scripts allow-same-origin"
class="w-full border-0"
style="height:{iframeHeight}px"
></iframe>
</div>
{/if}
@@ -0,0 +1,74 @@
<script lang="ts">
import type { TWidgetLayoutType } from '@cio/utils/validation/widget';
import { cn } from '@cio/ui/tools';
import { widgetLayoutThumbnailSrc } from '../utils/layout-thumbnail';
interface Props {
layoutType: TWidgetLayoutType;
label?: string;
/** Smaller frame for dense lists (e.g. org widgets index). */
compact?: boolean;
/** `contain` shows the full image (letterboxed); `cover` fills the frame (may crop). */
objectFit?: 'cover' | 'contain';
/** Strong primary border (e.g. layout picker selected state). */
selected?: boolean;
class?: string;
}
let { layoutType, label, compact = false, objectFit = 'cover', class: className = '' }: Props = $props();
let imgFailed = $state(false);
const accent: Record<TWidgetLayoutType, string> = {
card_grid: 'from-sky-500/15 via-cyan-500/8',
tag_filter: 'from-orange-500/15 via-amber-500/8',
carousel: 'from-fuchsia-500/15 via-pink-500/8',
primary_course: 'from-emerald-500/15 via-lime-500/8',
compact_list: 'from-violet-500/15 via-purple-500/8',
editorial_spotlight: 'from-rose-500/15 via-red-500/8',
category_shelf: 'from-indigo-500/15 via-blue-500/8'
};
$effect(() => {
void layoutType;
imgFailed = false;
});
</script>
<div
class={cn(
'ui:bg-background/95 relative w-full overflow-hidden rounded-md border',
compact ? 'aspect-5/3 max-h-28' : 'aspect-4/3',
className
)}
>
<div
aria-hidden="true"
class={cn(
'pointer-events-none absolute inset-x-0 -top-10 h-32 bg-gradient-to-br to-transparent opacity-70 blur-3xl',
accent[layoutType]
)}
></div>
{#if !imgFailed}
<img
src={widgetLayoutThumbnailSrc(layoutType)}
alt={label ?? layoutType}
class={cn(
'absolute inset-0 h-full w-full transition-transform',
objectFit === 'contain' ? 'object-contain object-center' : 'object-cover'
)}
width={compact ? 480 : 720}
height={compact ? 288 : 540}
loading="lazy"
decoding="async"
onerror={() => (imgFailed = true)}
/>
{:else}
<div class="relative flex h-full w-full items-center justify-center px-3 text-center">
<span class="ui:text-muted-foreground text-[11px] font-semibold tracking-[0.12em] uppercase">
{label ?? layoutType.replace('_', ' ')}
</span>
</div>
{/if}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import { CourseWidget, widgetStyles } from '@cio/ui/custom/widget-layouts';
import { t } from '$lib/utils/functions/translations';
let { payload = null }: { payload: TWidgetPayload | null } = $props();
let styleEl: HTMLStyleElement | null = null;
onMount(() => {
styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-widget-preview', '');
styleEl.textContent = widgetStyles;
document.head.appendChild(styleEl);
});
onDestroy(() => {
if (styleEl?.parentNode) {
styleEl.parentNode.removeChild(styleEl);
}
});
</script>
{#if !payload}
<div class="ui:text-muted-foreground rounded-2xl border border-dashed p-8 text-sm">
{$t('widgets.preview.empty')}
</div>
{:else}
<CourseWidget {payload} />
{/if}
@@ -0,0 +1,3 @@
export * from './api/widget.svelte';
export * from './pages';
export * from './utils/layout-thumbnail';
@@ -0,0 +1,2 @@
export { default as WidgetEditorPage } from './widget-editor.svelte';
export { default as WidgetsPage } from './widgets.svelte';
@@ -0,0 +1,175 @@
<script lang="ts">
import type { WidgetDetail } from '../utils/types';
import { widgetEditorStore } from '../store/widget-editor.store.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import WidgetIframePreview from '../components/widget-iframe-preview.svelte';
import WidgetEditorHeader from '../components/widget-editor-header.svelte';
import SelectCoursesPanel from '../panels/select-courses-panel.svelte';
import LayoutPanel from '../panels/layout-panel.svelte';
import DesignPanel from '../panels/design-panel.svelte';
import EmbedPanel from '../panels/embed-panel.svelte';
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
import PaletteIcon from '@lucide/svelte/icons/palette';
import Share2Icon from '@lucide/svelte/icons/share-2';
const store = widgetEditorStore;
let { detail: initialDetail }: { detail: WidgetDetail } = $props();
$effect(() => store.syncFromInitial(initialDetail));
$effect(() => {
if (!store.detail || !$currentOrg.id) return;
return store.startPreviewEffect();
});
function navButtonVariant(id: typeof store.activePanel): 'default' | 'ghost' {
return store.activePanel === id ? 'default' : 'ghost';
}
</script>
{#if store.detail}
<div class="ui:bg-background ui:text-foreground flex h-dvh flex-col">
<WidgetEditorHeader
bind:draftName={store.draftName}
status={store.detail.widget.status}
isPaidPlan={store.detail.planGatedFields.isPaidPlan}
isDirty={store.isDirty}
layoutValidationError={store.layoutValidationError}
editingName={store.editingName}
onSave={() => store.saveWidget()}
onPublish={() => store.publishWidget()}
onDiscard={() => store.resetDraft()}
onRenameStart={() => (store.editingName = true)}
onRenameFinish={() => (store.editingName = false)}
/>
<div class="flex min-h-0 flex-1">
<!-- Icon rail -->
<nav
class="ui:border-border ui:bg-secondary flex w-14 shrink-0 flex-col items-center gap-1.5 border-r py-3"
aria-label={$t('widgets.heading')}
>
<IconButton
type="button"
variant={navButtonVariant('select-courses')}
tooltip={$t('widgets.tabs.select_courses')}
tooltipSide="right"
aria-label={$t('widgets.tabs.select_courses')}
aria-current={store.activePanel === 'select-courses' ? 'page' : undefined}
onclick={() => (store.activePanel = 'select-courses')}
>
<GraduationCapIcon class="size-4" />
</IconButton>
<IconButton
type="button"
variant={navButtonVariant('layout')}
tooltip={$t('widgets.tabs.layout')}
tooltipSide="right"
aria-label={$t('widgets.tabs.layout')}
aria-current={store.activePanel === 'layout' ? 'page' : undefined}
onclick={() => (store.activePanel = 'layout')}
>
<LayoutGridIcon class="size-4" />
</IconButton>
<IconButton
type="button"
variant={navButtonVariant('design')}
tooltip={$t('widgets.tabs.design')}
tooltipSide="right"
aria-label={$t('widgets.tabs.design')}
aria-current={store.activePanel === 'design' ? 'page' : undefined}
onclick={() => (store.activePanel = 'design')}
>
<PaletteIcon class="size-4" />
</IconButton>
<IconButton
type="button"
variant={navButtonVariant('embed')}
tooltip={$t('widgets.tabs.embed')}
tooltipSide="right"
aria-label={$t('widgets.tabs.embed')}
aria-current={store.activePanel === 'embed' ? 'page' : undefined}
onclick={() => (store.activePanel = 'embed')}
>
<Share2Icon class="size-4" />
</IconButton>
</nav>
<!-- Settings panel -->
<aside class="ui:border-border ui:bg-card flex w-[min(100%,380px)] shrink-0 flex-col border-r">
<div class="ui:border-border border-b px-5 py-4">
{#if store.activePanel === 'select-courses'}
<h2 class="text-sm font-semibold">{$t('widgets.tabs.select_courses')}</h2>
{:else if store.activePanel === 'layout'}
<h2 class="text-sm font-semibold">{$t('widgets.editor.pick_layout')}</h2>
{:else if store.activePanel === 'design'}
<h2 class="text-sm font-semibold">{$t('widgets.editor.pick_design')}</h2>
{:else}
<h2 class="text-sm font-semibold">{$t('widgets.editor.embed_share')}</h2>
{/if}
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('widgets.editor.panel_subtitle')}</p>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{#if store.activePanel === 'select-courses'}
<SelectCoursesPanel
detail={store.detail}
bind:draftSelectionMode={store.draftSelectionMode}
bind:selectedCourseIds={store.selectedCourseIds}
bind:courseSearch={store.courseSearch}
bind:filterPublished={store.filterPublished}
bind:filterCourseType={store.filterCourseType}
bind:filterTagSlugs={store.filterTagSlugs}
filteredCourses={store.filteredCourses}
activeFilterCount={store.activeFilterCount}
onContinueToLayout={() => (store.activePanel = 'layout')}
onClearFilters={() => store.clearFilters()}
/>
{:else if store.activePanel === 'layout'}
<LayoutPanel
bind:draftLayoutType={store.draftLayoutType}
bind:draftConfig={store.draftConfig}
featuredCourseOptions={store.featuredCourseOptions}
tagOptions={store.tagOptions}
layoutValidationError={store.layoutValidationError}
onToggleCategoryTag={(tagId, checked) => store.toggleCategoryTag(tagId, checked)}
/>
{:else if store.activePanel === 'design'}
<DesignPanel
bind:draftConfig={store.draftConfig}
availableThemes={store.detail.planGatedFields.availableThemes}
canUseCustomColors={store.detail.planGatedFields.canUseCustomColors}
canUseCustomCss={store.detail.planGatedFields.canUseCustomCss}
isBrandingForced={store.detail.planGatedFields.isBrandingForced}
/>
{:else}
<EmbedPanel
detail={store.detail}
onRollback={(versionId) => store.rollback(versionId)}
onDelete={() => store.deleteWidget()}
/>
{/if}
</div>
</aside>
<!-- Preview canvas -->
<section
class="relative flex min-h-0 min-w-0 flex-1 flex-col bg-zinc-100 bg-[radial-gradient(circle,#d4d4d8_1px,transparent_1px)] bg-size-[18px_18px] dark:bg-zinc-950 dark:bg-[radial-gradient(circle,rgba(113,113,122,0.4)_1px,transparent_1px)]"
aria-label={$t('widgets.preview.heading')}
>
<div class="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 sm:p-6 lg:p-8">
<div
class="ui:border-border ui:bg-card/90 w-full max-w-5xl rounded-xl border p-4 shadow-sm backdrop-blur sm:p-8 dark:shadow-black/20"
>
<WidgetIframePreview payload={store.previewPayload} />
</div>
</div>
</section>
</div>
</div>
{/if}
@@ -0,0 +1,112 @@
<script lang="ts">
import { resolve } from '$app/paths';
import pluralize from 'pluralize';
import { widgetApi } from '../api/widget.svelte';
import type { WidgetListItem } from '../utils/types';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
import { Empty } from '@cio/ui/custom/empty';
import WidgetLayoutThumbnail from '../components/widget-layout-thumbnail.svelte';
import { cn } from '@cio/ui/tools';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import PanelsTopLeftIcon from '@lucide/svelte/icons/panels-top-left';
import WidgetCardDropdown from '../components/widget-card-dropdown.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
initialWidgets?: WidgetListItem[];
onCreate: () => void | Promise<void>;
}
let { initialWidgets = [] as WidgetListItem[], onCreate }: Props = $props();
$effect(() => {
widgetApi.widgets = initialWidgets;
});
function getStatusDotClass(status: string) {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === 'published') {
return 'bg-emerald-500';
}
if (normalizedStatus === 'draft') {
return 'bg-amber-500';
}
return 'ui:bg-muted-foreground/60';
}
</script>
{#if widgetApi.widgets.length === 0}
<Empty
title={$t('widgets.empty.heading')}
description={$t('widgets.empty.description')}
icon={PanelsTopLeftIcon}
variant="page"
>
<Button onclick={onCreate}>{$t('widgets.actions.create')}</Button>
</Empty>
{:else}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each widgetApi.widgets as widget (widget.id)}
{@const widgetHref = resolve(`/widgets/${widget.id}`, {})}
<Item.Root variant="outline" class="group ui:bg-card relative overflow-hidden rounded-2xl border p-0!">
<WidgetCardDropdown id={widget.id} name={widget.name} />
{#snippet child({ props })}
<a href={widgetHref} {...props} class={cn('block h-full w-full', props.class as string)}>
<div class="relative flex h-full w-full flex-col p-3.5">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 items-start gap-2.5">
<div
class="ui:bg-primary/10 ui:text-primary flex size-8 shrink-0 items-center justify-center rounded-lg"
>
<PanelsTopLeftIcon class="size-4" />
</div>
<div class="min-w-0">
<Item.Title class="ui:w-full text-sm! leading-tight font-semibold">
<span class="line-clamp-2">{widget.name}</span>
</Item.Title>
<Item.Description class="mt-0.5 line-clamp-1! text-xs">
{$t(`widgets.layout.${widget.layoutType}`)}
</Item.Description>
</div>
</div>
<span
class={cn('mt-1 block size-2 shrink-0 rounded-full', getStatusDotClass(widget.status))}
aria-label={widget.status}
title={widget.status}
></span>
</div>
<WidgetLayoutThumbnail
class="mt-3"
compact
layoutType={widget.layoutType}
label={$t(`widgets.layout.${widget.layoutType}`)}
/>
<div class="ui:text-muted-foreground mt-3 flex items-center justify-between gap-3 text-xs">
<span class="truncate">{$t(`widgets.selection.${widget.selectionMode}`)}</span>
<span class="shrink-0">
{pluralize($t('tags_admin.table.course'), widget.courseCount ?? 0, true)}
</span>
</div>
<div class="ui:border-border/60 mt-3 flex items-center justify-end border-t pt-2.5">
<Button variant="secondary" size="sm" href={widgetHref} class="h-8 gap-1 rounded-full px-3 text-xs">
{$t('widgets.actions.edit')}
<ArrowUpRightIcon size={14} />
</Button>
</div>
</div>
</a>
{/snippet}
</Item.Root>
{/each}
</div>
{/if}
@@ -0,0 +1,83 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { t } from '$lib/utils/functions/translations';
import type { WidgetDetail } from '../utils/types';
import type { WidgetConfig } from '../utils/types';
interface Props {
draftConfig: WidgetConfig;
availableThemes: WidgetDetail['planGatedFields']['availableThemes'];
canUseCustomColors: boolean;
canUseCustomCss: boolean;
isBrandingForced: boolean;
}
let {
draftConfig = $bindable(),
availableThemes,
canUseCustomColors,
canUseCustomCss,
isBrandingForced
}: Props = $props();
</script>
<div class="space-y-6">
<Field.Group>
<Field.Set>
<Field.Legend>{$t('widgets.editor.design')}</Field.Legend>
<Field.Field>
<Field.Label>{$t('widgets.form.theme_preset')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.themePreset}>
<Select.Trigger>{draftConfig.themePreset}</Select.Trigger>
<Select.Content>
{#each availableThemes as theme (theme)}
<Select.Item value={theme}>{theme}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<InputField label={$t('widgets.form.primary_color')} bind:value={draftConfig.colors.primaryColor} />
<InputField
label={$t('widgets.form.background_color')}
bind:value={draftConfig.colors.backgroundColor}
isDisabled={!canUseCustomColors}
/>
<InputField
label={$t('widgets.form.text_color')}
bind:value={draftConfig.colors.textColor}
isDisabled={!canUseCustomColors}
/>
<InputField
label={$t('widgets.form.border_color')}
bind:value={draftConfig.colors.borderColor}
isDisabled={!canUseCustomColors}
/>
<InputField
type="number"
label={$t('widgets.form.border_radius')}
bind:value={draftConfig.content.borderRadius}
/>
<InputField label={$t('widgets.form.font_family')} bind:value={draftConfig.typography.fontFamily} />
<InputField
type="number"
label={$t('widgets.form.font_scale')}
bind:value={draftConfig.typography.fontSizeScale}
/>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.branding.showPoweredBy} disabled={isBrandingForced} />
<Field.Label>{$t('widgets.form.show_powered_by')}</Field.Label>
</Field.Field>
<TextareaField
label={$t('widgets.form.custom_css')}
bind:value={draftConfig.advanced.customCss}
rows={6}
disabled={!canUseCustomCss}
helperMessage={!canUseCustomCss ? $t('widgets.form.custom_css_locked') : ''}
/>
</Field.Set>
</Field.Group>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { t } from '$lib/utils/functions/translations';
import type { WidgetDetail } from '../utils/types';
interface Props {
detail: WidgetDetail;
onRollback: (versionId: string) => void;
onDelete: () => void;
}
let { detail, onRollback, onDelete }: Props = $props();
</script>
<div class="space-y-6">
<TextareaField label={$t('widgets.form.embed_code')} value={detail.widget.embedCode} rows={6} readonly />
<div class="flex flex-wrap gap-2">
<Button variant="outline" onclick={() => navigator.clipboard.writeText(detail.widget.embedCode)}>
{$t('widgets.actions.copy_embed')}
</Button>
</div>
{#if detail.versions.length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold">{$t('widgets.editor.version_history')}</h3>
{#each detail.versions as version (version.id)}
<div class="ui:border-border ui:bg-card/50 flex items-center justify-between rounded-2xl border p-3">
<div>
<div class="font-medium">v{version.version}</div>
<div class="ui:text-muted-foreground text-xs">{version.publishedAt}</div>
</div>
<Button variant="outline" size="sm" onclick={() => onRollback(version.id)}>
{$t('widgets.actions.restore')}
</Button>
</div>
{/each}
</div>
{/if}
<div class="ui:border-border border-t pt-4">
<Button variant="outline" class="w-full" onclick={onDelete}>
{$t('widgets.actions.archive')}
</Button>
</div>
</div>
@@ -0,0 +1,160 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import * as Tooltip from '@cio/ui/base/tooltip';
import { Switch } from '@cio/ui/base/switch';
import { InputField } from '@cio/ui/custom/input-field';
import { cn } from '@cio/ui/tools';
import { t } from '$lib/utils/functions/translations';
import { WIDGET_LAYOUT_TYPE_VALUES } from '@cio/utils/validation/widget';
import WidgetLayoutThumbnail from '../components/widget-layout-thumbnail.svelte';
import LayoutOptions from '../components/layout-options.svelte';
import type { WidgetDetail } from '../utils/types';
import type { WidgetConfig } from '../utils/types';
interface Props {
draftLayoutType: WidgetDetail['widget']['layoutType'];
draftConfig: WidgetConfig;
featuredCourseOptions: { id: string; title: string }[];
tagOptions: WidgetDetail['availableTags'];
layoutValidationError: string | null;
onToggleCategoryTag: (tagId: string, checked: boolean) => void;
}
let {
draftLayoutType = $bindable(),
draftConfig = $bindable(),
featuredCourseOptions,
tagOptions,
layoutValidationError,
onToggleCategoryTag
}: Props = $props();
const layoutOptionIds = WIDGET_LAYOUT_TYPE_VALUES;
</script>
<div class="space-y-6">
<div>
<p class="ui:text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
{$t('widgets.form.layout_type')}
</p>
<Tooltip.Provider delayDuration={250}>
<div class="grid grid-cols-2 gap-3">
{#each layoutOptionIds as layoutId (layoutId)}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<div {...props} class={cn('space-y-1', props.class as string | undefined)}>
<p class="w-full text-sm">
{$t(`widgets.layout.${layoutId}`)}
</p>
<button
type="button"
class={cn(
'ui:bg-muted/40 group focus-visible:ui:ring-primary/40 flex w-full cursor-pointer flex-col overflow-hidden rounded-md border-2 p-2 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950',
draftLayoutType === layoutId
? 'ui:border-primary shadow-sm'
: 'ui:border-border ui:hover:border-foreground/20'
)}
aria-label={$t(`widgets.layout.${layoutId}`)}
onclick={() => (draftLayoutType = layoutId)}
>
<WidgetLayoutThumbnail
layoutType={layoutId}
label={$t(`widgets.layout.${layoutId}`)}
objectFit="contain"
selected={draftLayoutType === layoutId}
class="h-auto"
/>
</button>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" sideOffset={16} class="ui:max-w-none h-50! w-60! p-3 text-left">
<div class="w-full space-y-1">
<p class="w-full text-center text-sm font-semibold">
{$t(`widgets.layout.${layoutId}`)}
</p>
<WidgetLayoutThumbnail
layoutType={layoutId}
label={$t(`widgets.layout.${layoutId}`)}
objectFit="contain"
class="ui:border-border ui:bg-card rounded-md border shadow-lg"
/>
</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</Tooltip.Provider>
</div>
{#if layoutValidationError}
<div
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
>
{$t(layoutValidationError)}
</div>
{/if}
<Field.Group>
<Field.Set>
<Field.Legend>{$t('widgets.layoutOptions.section')}</Field.Legend>
<LayoutOptions {draftLayoutType} bind:draftConfig {featuredCourseOptions} {tagOptions} {onToggleCategoryTag} />
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('widgets.editor.layout')}</Field.Legend>
<Field.Field>
<Field.Label>{$t('widgets.form.sort_by')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.sortBy}>
<Select.Trigger>{$t(`widgets.sort.${draftConfig.sortBy}`)}</Select.Trigger>
<Select.Content>
<Select.Item value="manual">{$t('widgets.sort.manual')}</Select.Item>
<Select.Item value="newest">{$t('widgets.sort.newest')}</Select.Item>
<Select.Item value="title">{$t('widgets.sort.title')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<InputField label={$t('widgets.form.cta_label')} bind:value={draftConfig.content.ctaLabel} />
<InputField label={$t('widgets.form.load_more_label')} bind:value={draftConfig.content.loadMoreLabel} />
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('widgets.editor.content')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showCourseImage} />
<Field.Label>{$t('widgets.form.show_course_image')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showCourseTypeBadge} />
<Field.Label>{$t('widgets.form.show_type_badge')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showLessonsCount} />
<Field.Label>{$t('widgets.form.show_lessons_count')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showDescriptionExcerpt} />
<Field.Label>{$t('widgets.form.show_description')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.shortenLongDescription} />
<Field.Label>{$t('widgets.form.shorten_long_description')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showPrice} />
<Field.Label>{$t('widgets.form.show_price')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showRating} />
<Field.Label>{$t('widgets.form.show_rating')}</Field.Label>
</Field.Field>
</Field.Set>
</Field.Group>
</div>
@@ -0,0 +1,152 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Search } from '@cio/ui/custom/search';
import { CheckboxOptionCardGroup, type CheckboxOptionCardOption } from '@cio/ui/custom/checkbox-option-card';
import { CourseTagsOverflow } from '$features/course/components';
import CourseFilterPopover from '../components/course-filter-popover.svelte';
import { t } from '$lib/utils/functions/translations';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import type { WidgetDetail } from '../utils/types';
import type { FilterPublished } from '../utils/widget-editor-utils';
interface Props {
detail: WidgetDetail;
draftSelectionMode: WidgetDetail['widget']['selectionMode'];
selectedCourseIds: string[];
courseSearch: string;
filterPublished: FilterPublished;
filterCourseType: string;
filterTagSlugs: string[];
filteredCourses: WidgetDetail['availableCourses'];
activeFilterCount: number;
onContinueToLayout: () => void;
onClearFilters: () => void;
}
let {
detail,
draftSelectionMode = $bindable(),
selectedCourseIds = $bindable(),
courseSearch = $bindable(),
filterPublished = $bindable(),
filterCourseType = $bindable(),
filterTagSlugs = $bindable(),
filteredCourses,
activeFilterCount,
onContinueToLayout,
onClearFilters
}: Props = $props();
const courseTypeLabels = $derived<Record<string, string>>({
LIVE_CLASS: $t('new_course_modal.live_class_label'),
SELF_PACED: $t('new_course_modal.self_paced_label'),
COMPLIANCE: $t('new_course_modal.compliance_label')
});
const courseTypeOptions = $derived([
{ value: 'LIVE_CLASS', label: $t('new_course_modal.live_class_label') },
{ value: 'SELF_PACED', label: $t('new_course_modal.self_paced_label') },
{ value: 'COMPLIANCE', label: $t('new_course_modal.compliance_label') }
]);
const courseOptions = $derived<CheckboxOptionCardOption[]>(
filteredCourses.map((course) => ({
id: course.id,
title: course.title,
value: course.id
}))
);
</script>
<div class="space-y-4">
<Button type="button" class="ui:justify-between w-full" onclick={onContinueToLayout}>
{$t('widgets.editor.continue_to_layout')}
<ChevronRightIcon class="size-4" />
</Button>
<Field.Group>
<Field.Set>
<Field.Legend>{$t('widgets.editor.sources')}</Field.Legend>
<Field.Field>
<Field.Label>{$t('widgets.form.selection_mode')}</Field.Label>
<Select.Root type="single" bind:value={draftSelectionMode}>
<Select.Trigger>
{draftSelectionMode === 'manual' ? $t('widgets.selection.manual') : $t('widgets.selection.published')}
</Select.Trigger>
<Select.Content>
<Select.Item value="manual">{$t('widgets.selection.manual')}</Select.Item>
<Select.Item value="published">{$t('widgets.selection.published')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('widgets.editor.available_courses')}</Field.Legend>
<div class="flex items-center gap-2">
<Search class="ui:max-w-none flex-1" placeholder={$t('widgets.form.course_search')} bind:value={courseSearch} />
<CourseFilterPopover
{activeFilterCount}
{filterPublished}
{filterCourseType}
{filterTagSlugs}
availableTags={detail.availableTags}
{courseTypeOptions}
onClearAll={onClearFilters}
onFilterPublishedChange={(val) => (filterPublished = val)}
onFilterCourseTypeChange={(val) => (filterCourseType = val)}
onFilterTagSlugsChange={(slugs) => (filterTagSlugs = slugs)}
/>
</div>
{#if courseOptions.length === 0}
<p class="ui:text-muted-foreground px-1 py-6 text-center text-xs">
{$t('widgets.preview.empty')}
</p>
{:else}
<CheckboxOptionCardGroup options={courseOptions} bind:value={selectedCourseIds} class="ui:grid-cols-1">
{#snippet titleSuffix(option)}
{@const course = filteredCourses.find((entry) => entry.id === option.id)}
{#if course?.courseType && courseTypeLabels[course.courseType]}
<Badge variant="outline" class="shrink-0 text-[10px]">{courseTypeLabels[course.courseType]}</Badge>
{/if}
{/snippet}
{#snippet footer(option)}
{@const course = filteredCourses.find((entry) => entry.id === option.id)}
{#if course}
<div class="space-y-1.5">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs">
{#if course.isPublished}
<span class="inline-flex items-center gap-1 font-medium text-emerald-600">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
{$t('widgets.status.live')}
</span>
{:else}
<span class="ui:text-muted-foreground inline-flex items-center gap-1">
<span class="ui:bg-muted-foreground/50 h-1.5 w-1.5 rounded-full"></span>
{$t('widgets.filter.unpublished')}
</span>
{/if}
<span class="ui:text-muted-foreground/50">·</span>
<span>{course.lessonCount} {$t('widgets.preview.lessons_suffix')}</span>
{#if course.exerciseCount > 0}
<span class="ui:text-muted-foreground/50">·</span>
<span>{course.exerciseCount} {$t('widgets.preview.exercises_suffix')}</span>
{/if}
</div>
{#if course.tags.length > 0}
<CourseTagsOverflow tags={course.tags} variant="card" />
{/if}
</div>
{/if}
{/snippet}
</CheckboxOptionCardGroup>
{/if}
</Field.Set>
</Field.Group>
</div>
@@ -0,0 +1,246 @@
import { getDefaultWidgetConfig } from '@cio/utils/validation/widget';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import type { WidgetDetail } from '../utils/types';
import { widgetApi } from '../api/widget.svelte';
import { deepPlain, buildCourseFilterPredicate, type FilterPublished } from '../utils/widget-editor-utils';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { currentOrgPath } from '$lib/utils/store/org';
import { get } from 'svelte/store';
export type PanelId = 'select-courses' | 'layout' | 'design' | 'embed';
class WidgetEditorStore {
// ── persisted detail (last server-synced) ──────────────────────────────────
detail = $state<WidgetDetail | null>(null);
// ── draft fields ───────────────────────────────────────────────────────────
draftName = $state('');
draftLayoutType = $state<WidgetDetail['widget']['layoutType']>('card_grid');
draftSelectionMode = $state<WidgetDetail['widget']['selectionMode']>('manual');
draftConfig = $state(getDefaultWidgetConfig());
selectedCourseIds = $state<string[]>([]);
// ── UI state ───────────────────────────────────────────────────────────────
activePanel = $state<PanelId>('select-courses');
editingName = $state(false);
courseSearch = $state('');
filterPublished = $state<FilterPublished>('all');
filterCourseType = $state('all');
filterTagSlugs = $state<string[]>([]);
// ── preview ────────────────────────────────────────────────────────────────
previewPayload = $state<TWidgetPayload | null>(null);
#previewRequestSeq = 0;
// ── sync guard ─────────────────────────────────────────────────────────────
#syncedDetailSignature = $state('');
// ── derived ────────────────────────────────────────────────────────────────
readonly featuredCourseOptions = $derived.by(() => {
if (!this.detail) return [];
const candidateIds =
this.draftSelectionMode === 'manual'
? this.selectedCourseIds
: this.detail.availableCourses.map((course) => course.id);
const lookup = new Map(this.detail.availableCourses.map((course) => [course.id, course] as const));
return candidateIds
.map((id) => lookup.get(id))
.filter((course): course is NonNullable<ReturnType<typeof lookup.get>> => Boolean(course))
.map((course) => ({ id: course.id, title: course.title }));
});
readonly tagOptions = $derived(this.detail?.availableTags ?? []);
readonly layoutValidationError = $derived.by((): string | null => {
if (!this.detail) return null;
if (this.draftLayoutType === 'primary_course' && !this.draftConfig.layoutOptions.primaryCourse.featuredCourseId) {
return 'widgets.layoutOptions.featured_course_required';
}
if (
this.draftLayoutType === 'editorial_spotlight' &&
!this.draftConfig.layoutOptions.editorialSpotlight.mainCourseId
) {
return 'widgets.layoutOptions.main_course_required';
}
if (
this.draftLayoutType === 'category_shelf' &&
this.draftSelectionMode === 'published' &&
this.draftConfig.layoutOptions.categoryShelf.categoryTagIds.length === 0
) {
return 'widgets.layoutOptions.category_tags_required';
}
return null;
});
readonly filteredCourses = $derived.by(() => {
if (!this.detail) return [];
const predicate = buildCourseFilterPredicate({
courseSearch: this.courseSearch,
filterPublished: this.filterPublished,
filterCourseType: this.filterCourseType,
filterTagSlugs: this.filterTagSlugs
});
return this.detail.availableCourses.filter(predicate);
});
readonly activeFilterCount = $derived(
(this.filterPublished !== 'all' ? 1 : 0) + (this.filterCourseType !== 'all' ? 1 : 0) + this.filterTagSlugs.length
);
readonly isDirty = $derived(
this.detail !== null &&
JSON.stringify({
name: this.draftName,
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
config: this.draftConfig,
selectedCourseIds: this.selectedCourseIds
}) !==
JSON.stringify({
name: this.detail.widget.name,
layoutType: this.detail.widget.layoutType,
selectionMode: this.detail.widget.selectionMode,
config: this.detail.widget.config,
selectedCourseIds: this.detail.widget.selectedCourseIds
})
);
// ── public mutations ───────────────────────────────────────────────────────
applyDetail(nextDetail: WidgetDetail) {
this.detail = nextDetail;
this.draftName = nextDetail.widget.name;
this.draftLayoutType = nextDetail.widget.layoutType;
this.draftSelectionMode = nextDetail.widget.selectionMode;
this.draftConfig = deepPlain(nextDetail.widget.config);
this.selectedCourseIds = [...nextDetail.widget.selectedCourseIds];
}
syncFromInitial(initialDetail: WidgetDetail) {
const nextSignature = [
initialDetail.widget.id,
initialDetail.widget.updatedAt,
initialDetail.widget.latestPublishedVersionId ?? ''
].join(':');
if (this.#syncedDetailSignature === nextSignature) return;
this.#syncedDetailSignature = nextSignature;
this.applyDetail(initialDetail);
}
resetDraft() {
if (!this.detail) return;
this.applyDetail(this.detail);
}
clearFilters() {
this.filterPublished = 'all';
this.filterCourseType = 'all';
this.filterTagSlugs = [];
}
toggleCategoryTag(tagId: string, checked: boolean) {
const current = this.draftConfig.layoutOptions.categoryShelf.categoryTagIds;
this.draftConfig.layoutOptions.categoryShelf.categoryTagIds = checked
? [...current, tagId]
: current.filter((id) => id !== tagId);
if (!checked && this.draftConfig.layoutOptions.categoryShelf.defaultCategoryTagId === tagId) {
this.draftConfig.layoutOptions.categoryShelf.defaultCategoryTagId = null;
}
}
async saveWidget(silent = false): Promise<boolean> {
if (!this.detail) return false;
const updatedWidget = await widgetApi.updateWidget(
this.detail.widget.id,
{
name: this.draftName,
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
config: this.draftConfig,
selectedCourseIds: this.selectedCourseIds
},
silent
);
if (!updatedWidget) return false;
await widgetApi.getWidget(this.detail.widget.id);
if (widgetApi.widgetDetail) {
this.applyDetail(widgetApi.widgetDetail);
}
return true;
}
async publishWidget() {
const saved = await this.saveWidget(true);
if (!saved || !this.detail) return;
const published = await widgetApi.publishWidget(this.detail.widget.id);
if (!published) return;
await widgetApi.getWidget(this.detail.widget.id);
if (widgetApi.widgetDetail) {
this.applyDetail(widgetApi.widgetDetail);
}
this.activePanel = 'embed';
}
async rollback(versionId: string) {
if (!this.detail) return;
await widgetApi.rollbackWidget(this.detail.widget.id, versionId);
await widgetApi.getWidget(this.detail.widget.id);
if (widgetApi.widgetDetail) {
this.applyDetail(widgetApi.widgetDetail);
}
}
async deleteWidget() {
if (!this.detail) return;
await widgetApi.deleteWidget(this.detail.widget.id);
goto(resolve(`${get(currentOrgPath)}/widgets`, {}));
}
/** Kicks off a debounced preview fetch. Call inside a `$effect`. */
startPreviewEffect() {
if (!this.detail) return;
// Subscribing to JSON.stringify forces deep reactivity on nested $state mutations.
void JSON.stringify({
config: this.draftConfig,
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
selectedCourseIds: this.selectedCourseIds
});
const snapshot = {
config: deepPlain(this.draftConfig),
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
selectedCourseIds: [...this.selectedCourseIds]
};
const widgetId = this.detail.widget.id;
const seq = ++this.#previewRequestSeq;
let cancelled = false;
void (async () => {
const payload = await widgetApi.previewWidget(widgetId, snapshot);
if (cancelled || seq !== this.#previewRequestSeq || !payload) return;
this.previewPayload = payload;
})();
return () => {
cancelled = true;
};
}
}
export const widgetEditorStore = new WidgetEditorStore();
@@ -0,0 +1,8 @@
import type { TWidgetLayoutType } from '@cio/utils/validation/widget';
/** Served from `apps/dashboard/static/templates/widget-layouts/*.png` (~720px max edge for sharp downscaling). */
export const WIDGET_LAYOUT_THUMBNAIL_BASE = '/templates/widget-layouts';
export function widgetLayoutThumbnailSrc(layoutType: TWidgetLayoutType): string {
return `${WIDGET_LAYOUT_THUMBNAIL_BASE}/${layoutType}.png`;
}
@@ -0,0 +1,67 @@
import type { TWidgetPayload } from '@cio/utils/validation/widget';
// Parent → iframe messages
export type WidgetPreviewParentMessage =
| { type: 'WIDGET_PREVIEW_RENDER'; payload: TWidgetPayload; requestId: string }
| { type: 'WIDGET_PREVIEW_PING' };
// Iframe → parent messages
export type WidgetPreviewIframeMessage =
| { type: 'WIDGET_PREVIEW_READY'; iframeId: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_RENDERED'; requestId: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_ERROR'; requestId: string; error: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_RESIZE'; height: number };
export type WidgetPreviewMessage = WidgetPreviewParentMessage | WidgetPreviewIframeMessage;
const VALID_PARENT_TYPES = new Set(['WIDGET_PREVIEW_RENDER', 'WIDGET_PREVIEW_PING']);
const VALID_IFRAME_TYPES = new Set([
'WIDGET_PREVIEW_READY',
'WIDGET_PREVIEW_RENDERED',
'WIDGET_PREVIEW_ERROR',
'WIDGET_PREVIEW_RESIZE'
]);
export function isValidParentMessage(data: unknown): data is WidgetPreviewParentMessage {
if (!data || typeof data !== 'object' || !('type' in data)) {
return false;
}
return VALID_PARENT_TYPES.has((data as { type: string }).type);
}
export function isValidIframeMessage(data: unknown): data is WidgetPreviewIframeMessage {
if (!data || typeof data !== 'object' || !('type' in data)) {
return false;
}
return VALID_IFRAME_TYPES.has((data as { type: string }).type);
}
export function getAllowedOrigins(): string[] {
const origins: string[] = [];
const dashboardUrl = typeof window !== 'undefined' ? window.location.origin : '';
if (dashboardUrl) {
origins.push(dashboardUrl);
}
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
origins.push(`http://localhost:${window.location.port}`);
}
return origins;
}
export function isAllowedOrigin(origin: string): boolean {
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
try {
const url = new URL(origin);
return url.hostname === 'localhost';
} catch {
return false;
}
}
return origin === window.location.origin;
}
@@ -0,0 +1,34 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import type {
TCreateWidget,
TPreviewWidget,
TUpdateWidget,
TWidgetConfig,
TWidgetDetail,
TWidgetPayload
} from '@cio/utils/validation/widget';
export type GetWidgetsRequest = (typeof classroomio.organization)['widgets']['$get'];
export type GetWidgetDetailRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$get'];
export type CreateWidgetRequest = (typeof classroomio.organization)['widgets']['$post'];
export type UpdateWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$put'];
export type PreviewWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['preview']['$post'];
export type PublishWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['publish']['$post'];
export type RollbackWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['rollback']['$post'];
export type DeleteWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$delete'];
export type GetWidgetsSuccess = Extract<InferResponseType<GetWidgetsRequest>, { success: true }>;
export type WidgetListItem = GetWidgetsSuccess['data'][number];
export type GetWidgetDetailSuccess = Extract<InferResponseType<GetWidgetDetailRequest>, { success: true }>;
export type WidgetDetailResponse = GetWidgetDetailSuccess['data'];
export type WidgetDetail = TWidgetDetail & WidgetDetailResponse;
export type PublishWidgetSuccess = Extract<InferResponseType<PublishWidgetRequest>, { success: true }>;
export type RollbackWidgetSuccess = Extract<InferResponseType<RollbackWidgetRequest>, { success: true }>;
export type WidgetConfig = TWidgetConfig;
export type WidgetPayload = TWidgetPayload;
export type CreateWidgetInput = TCreateWidget;
export type UpdateWidgetInput = TUpdateWidget;
export type PreviewWidgetInput = TPreviewWidget;
@@ -0,0 +1,29 @@
import type { WidgetDetail } from './types';
/** Plain deep clone — `structuredClone` throws on Svelte `$state` proxies. */
export function deepPlain<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export type FilterPublished = 'all' | 'published' | 'unpublished';
export function buildCourseFilterPredicate(opts: {
courseSearch: string;
filterPublished: FilterPublished;
filterCourseType: string;
filterTagSlugs: string[];
}) {
return (course: WidgetDetail['availableCourses'][number]): boolean => {
if (opts.courseSearch && !course.title.toLowerCase().includes(opts.courseSearch.toLowerCase())) return false;
if (opts.filterPublished === 'published' && !course.isPublished) return false;
if (opts.filterPublished === 'unpublished' && course.isPublished) return false;
if (opts.filterCourseType !== 'all' && course.courseType !== opts.filterCourseType) return false;
if (
opts.filterTagSlugs.length > 0 &&
!opts.filterTagSlugs.some((slug) => course.tags.some((tag) => tag.slug === slug))
)
return false;
return true;
};
}