feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -0,0 +1,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();