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,127 @@
const WIDGET_ATTR = 'data-cio-widget';
const WIDGET_VALUE = 'course-widget';
const WIDGET_KEY_ATTR = 'data-widget-key';
const API_BASE_ATTR = 'data-api-base-url';
const LOADING_CLASS = 'cio-embed-loading';
const EMBED_MIN_HEIGHT_PX = 360;
export type CourseWidgetEmbedApi = {
mount: (element: HTMLElement, options: { publicKey: string; apiBaseUrl?: string }) => { destroy: () => void };
};
declare global {
interface Window {
CIO?: {
apiBaseUrl?: string;
courseWidget?: CourseWidgetEmbedApi;
};
}
}
let stylesInjected = false;
function injectSpinnerStyles(): void {
if (stylesInjected) return;
stylesInjected = true;
const css = `
@keyframes cio-embed-spin { to { transform: rotate(360deg); } }
.${LOADING_CLASS} {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: ${EMBED_MIN_HEIGHT_PX}px;
padding: 1rem;
color: oklch(0.556 0 0);
}
.${LOADING_CLASS} svg {
animation: cio-embed-spin 0.8s linear infinite;
flex-shrink: 0;
}
`;
const styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-embed', 'course-widget-loading');
styleEl.textContent = css;
document.head.appendChild(styleEl);
}
const LOADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v4"/><path d="m16.24 7.76 2.83-2.83"/><path d="M22 12h-4"/><path d="m16.24 16.24 2.83 2.83"/><path d="M12 22v-4"/><path d="m7.76 16.24-2.83 2.83"/><path d="M2 12h4"/><path d="m7.76 7.76-2.83-2.83"/></svg>`;
function renderLoading(container: HTMLElement): void {
injectSpinnerStyles();
const root = document.createElement('div');
root.className = LOADING_CLASS;
root.setAttribute('role', 'status');
root.setAttribute('aria-live', 'polite');
root.setAttribute('aria-label', 'Loading');
root.innerHTML = LOADER_SVG;
container.replaceChildren(root);
}
function collectTargets(): Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }> {
return [...document.querySelectorAll<HTMLElement>(`[${WIDGET_ATTR}="${WIDGET_VALUE}"]`)]
.map((element) => ({
element,
publicKey: element.getAttribute(WIDGET_KEY_ATTR) ?? '',
apiBaseUrl: element.getAttribute(API_BASE_ATTR) ?? window.CIO?.apiBaseUrl
}))
.filter((target) => Boolean(target.publicKey));
}
function scheduleMount(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void import('./embed-mount.ts').then((mod) => {
mod.mountAllAuto(targets);
});
});
});
}
function manualMount(
element: HTMLElement,
options: { publicKey: string; apiBaseUrl?: string }
): { destroy: () => void } {
renderLoading(element);
let cancelled = false;
let destroyWidget: (() => void) | null = null;
void import('./embed-mount.ts').then((mod) => {
if (cancelled) return;
destroyWidget = mod.mountWidget(element, options).destroy;
});
return {
destroy: () => {
cancelled = true;
destroyWidget?.();
element.replaceChildren();
}
};
}
function init(): void {
const targets = collectTargets();
for (const target of targets) {
renderLoading(target.element);
}
window.CIO = window.CIO ?? {};
window.CIO.courseWidget = {
mount: manualMount
};
scheduleMount(targets);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
@@ -0,0 +1,51 @@
import { mount, unmount } from 'svelte';
import { widgetStyles } from '@cio/ui/custom/widget-layouts';
import Widget from './widget.svelte';
import shellCss from './widget.css?raw';
function createShadowHost(target: HTMLElement): { shadowRoot: ShadowRoot; mountPoint: HTMLElement } {
target.replaceChildren();
const host = document.createElement('cio-course-widget');
target.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });
const styleElement = document.createElement('style');
styleElement.textContent = `${shellCss}\n${widgetStyles}`;
shadowRoot.appendChild(styleElement);
const mountPoint = document.createElement('div');
shadowRoot.appendChild(mountPoint);
return { shadowRoot, mountPoint };
}
export function mountWidget(
target: HTMLElement,
options: { publicKey: string; apiBaseUrl?: string }
): { destroy: () => void } {
const { mountPoint } = createShadowHost(target);
const instance = mount(Widget, {
target: mountPoint,
props: options
});
return {
destroy: () => {
unmount(instance);
target.replaceChildren();
}
};
}
export function mountAllAuto(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void {
for (const target of targets) {
mountWidget(target.element, {
publicKey: target.publicKey,
apiBaseUrl: target.apiBaseUrl
});
}
}
@@ -0,0 +1,44 @@
:host {
all: initial;
display: block;
width: 100%;
}
.cio-widget-shell {
font-family: 'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif;
color: rgba(15, 23, 42, 0.8);
background: transparent;
display: block;
width: 100%;
box-sizing: border-box;
padding: 16px;
}
.cio-widget-loading,
.cio-widget-error {
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.cio-widget-error p {
margin: 0;
font-size: 14px;
color: #b91c1c;
}
.cio-widget-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(15, 23, 42, 0.1);
border-top-color: rgba(15, 23, 42, 0.6);
border-radius: 999px;
animation: cio-widget-spin 0.8s linear infinite;
}
@keyframes cio-widget-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import { CourseWidget } from '@cio/ui/custom/widget-layouts';
const DEFAULT_API_BASE_URL = 'https://api.classroomio.com';
let { publicKey, apiBaseUrl = DEFAULT_API_BASE_URL }: { publicKey: string; apiBaseUrl?: string } = $props();
let payload = $state<TWidgetPayload | null>(null);
let errorMessage = $state('');
async function loadPayload() {
try {
errorMessage = '';
const response = await fetch(
`${(apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, '')}/widgets/${publicKey}/payload`
);
const result = (await response.json()) as { success?: boolean; data?: TWidgetPayload; error?: string };
if (!response.ok || !result.success || !result.data) {
throw new Error(result.error || 'Failed to load widget');
}
payload = result.data;
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Failed to load widget';
}
}
onMount(() => {
void loadPayload();
});
</script>
{#if errorMessage}
<div class="cio-widget-shell cio-widget-error">
<p>{errorMessage}</p>
</div>
{:else if !payload}
<div class="cio-widget-shell cio-widget-loading">
<div class="cio-widget-spinner" aria-hidden="true"></div>
</div>
{:else}
<CourseWidget {payload} />
{/if}