feat: widgets and ai chat
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user