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