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