feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user