feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -0,0 +1,3 @@
export { sidePanel } from './utils/store.svelte';
export type { SidePanelDefinition, SidePanelScope } from './utils/types';
export { default as SidePanelRail } from './side-panel-rail.svelte';
@@ -0,0 +1,166 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { t } from '$lib/utils/functions/translations';
import { sidePanel } from './utils/store.svelte';
let railShellElement: HTMLDivElement | null = $state(null);
let railWidth = $state(0);
let isWidthLoaded = $state(false);
let isRailResizing = $state(false);
let stopRailResize: (() => void) | null = null;
const activeDefinition = $derived(sidePanel.activeDefinition);
const activeProps = $derived(sidePanel.panelProps);
function clampWidth(width: number) {
const def = activeDefinition;
if (!def) return width;
return Math.min(def.maxWidth, Math.max(def.minWidth, width));
}
function clearResizeListeners() {
stopRailResize?.();
stopRailResize = null;
}
function loadWidthForDefinition(def: typeof activeDefinition): number {
if (!def) return 0;
try {
const stored = Number(localStorage.getItem(def.widthStorageKey));
if (Number.isFinite(stored) && stored > 0) {
return Math.min(def.maxWidth, Math.max(def.minWidth, stored));
}
} catch {
// localStorage unavailable
}
return def.defaultWidth;
}
function persistWidthForDefinition(def: typeof activeDefinition, width: number) {
if (!def) return;
try {
localStorage.setItem(def.widthStorageKey, String(Math.round(width)));
} catch {
// localStorage unavailable
}
}
$effect(() => {
const def = activeDefinition;
if (!def) {
railWidth = 0;
isWidthLoaded = false;
return;
}
railWidth = loadWidthForDefinition(def);
isWidthLoaded = true;
});
$effect(() => {
if (!isWidthLoaded) return;
persistWidthForDefinition(activeDefinition, railWidth);
});
// Lesson-scoped panels should not survive navigating to a different lesson.
// The rail owns this so host layouts don't have to wire it up themselves.
let lastLessonId: string | undefined = undefined;
$effect(() => {
const lessonId = page.params?.lessonId as string | undefined;
if (lastLessonId !== undefined && lessonId !== lastLessonId) {
sidePanel.closeIfScope('lesson');
}
lastLessonId = lessonId;
});
function handleResizePointerDown(event: PointerEvent) {
if (!activeDefinition) return;
event.preventDefault();
event.stopPropagation();
clearResizeListeners();
const startWidth = railWidth;
stopRailResize = startResizablePanelDrag({
event,
startWidth,
resolveWidth: ({ startWidth, deltaX }) => clampWidth(startWidth - deltaX),
onPreview: (width) => {
railShellElement?.style.setProperty('--side-panel-width', `${width}px`);
},
onCommit: ({ width }) => {
railWidth = width;
},
onDragStart: () => {
isRailResizing = true;
},
onDragEnd: () => {
isRailResizing = false;
stopRailResize = null;
}
});
}
onMount(() => {
return () => {
clearResizeListeners();
};
});
onDestroy(() => {
clearResizeListeners();
sidePanel.reset();
});
</script>
{#if activeDefinition}
{@const def = activeDefinition}
<div
bind:this={railShellElement}
data-side-panel-resizing={isRailResizing}
class="contents"
style={`--side-panel-width: ${railWidth}px;`}
>
<div class="hidden shrink-0 md:block md:w-(--side-panel-width)"></div>
<aside
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--side-panel-width)"
aria-label={t.get(def.titleKey)}
>
<button
type="button"
aria-label={t.get('side_panel.resize_panel_aria_label')}
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
onpointerdown={handleResizePointerDown}
>
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
</button>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
{#key def.id}
<def.component {...activeProps} />
{/key}
</div>
</aside>
</div>
{/if}
<style>
:global([data-side-panel-resizing='true'] *) {
transition-property: none !important;
}
</style>
@@ -0,0 +1,69 @@
import type { SidePanelDefinition, SidePanelScope } from './types';
class SidePanelStore {
private definitions = $state<Map<string, SidePanelDefinition>>(new Map());
activePanelId = $state<string | null>(null);
panelProps = $state<Record<string, unknown>>({});
register(definition: SidePanelDefinition) {
this.definitions.set(definition.id, definition);
}
list(): SidePanelDefinition[] {
return Array.from(this.definitions.values());
}
get(id: string): SidePanelDefinition | undefined {
return this.definitions.get(id);
}
get activeDefinition(): SidePanelDefinition | null {
if (!this.activePanelId) return null;
return this.definitions.get(this.activePanelId) ?? null;
}
open(id: string, props: Record<string, unknown> = {}) {
if (!this.definitions.has(id)) {
console.warn(`sidePanel.open: no panel registered with id "${id}"`);
return;
}
this.activePanelId = id;
this.panelProps = props;
}
toggle(id: string, props: Record<string, unknown> = {}) {
if (this.activePanelId === id) {
this.close();
return;
}
this.open(id, props);
}
close() {
this.activePanelId = null;
this.panelProps = {};
}
closeIfScope(scope: SidePanelScope) {
if (!this.activePanelId) return;
const def = this.definitions.get(this.activePanelId);
if (!def) return;
if (def.scope === scope) {
this.close();
}
}
reset() {
this.close();
}
}
export const sidePanel = new SidePanelStore();
@@ -0,0 +1,21 @@
import type { Component } from 'svelte';
export type SidePanelScope = 'course' | 'lesson';
export interface SidePanelDefinition {
id: string;
/** i18n key for the panel header title. */
titleKey: string;
/**
* Lesson-scoped panels close automatically when the route's lessonId changes.
* Course-scoped panels stay open while the user navigates lessons in the same course.
*/
scope: SidePanelScope;
/** Body component rendered inside the rail. Receives the props passed to `open(id, props)`. */
component: Component<Record<string, unknown>>;
defaultWidth: number;
minWidth: number;
maxWidth: number;
/** localStorage key for the panel's last user-resized width. */
widthStorageKey: string;
}