feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import InfoIcon from '@lucide/svelte/icons/info';
|
||||
import * as Popover from '../../base/popover';
|
||||
import * as Select from '../../base/select';
|
||||
import { Button } from '../../base/button';
|
||||
import { Textarea } from '../../base/textarea';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
type Tone = 'professional' | 'casual' | 'expert' | 'friendly';
|
||||
|
||||
interface Props {
|
||||
onGenerate: (prompt: string, tone: Tone) => Promise<void>;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { onGenerate, align = 'end', class: className }: Props = $props();
|
||||
|
||||
const TONE_STORAGE_KEY = 'ai-generate-popover-tone';
|
||||
|
||||
function readStoredTone(): Tone {
|
||||
try {
|
||||
const stored = localStorage.getItem(TONE_STORAGE_KEY);
|
||||
if (stored === 'professional' || stored === 'casual' || stored === 'expert' || stored === 'friendly') {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
return 'expert';
|
||||
}
|
||||
|
||||
let open = $state(false);
|
||||
let prompt = $state('');
|
||||
let tone = $state<Tone>(readStoredTone());
|
||||
let loading = $state(false);
|
||||
|
||||
function handleToneChange(value: string) {
|
||||
tone = value as Tone;
|
||||
try {
|
||||
localStorage.setItem(TONE_STORAGE_KEY, tone);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const toneOptions: { value: Tone; label: string }[] = [
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
{ value: 'casual', label: 'Casual' },
|
||||
{ value: 'expert', label: 'Expert' },
|
||||
{ value: 'friendly', label: 'Friendly' }
|
||||
];
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!prompt.trim() || loading) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
await onGenerate(prompt.trim(), tone);
|
||||
open = false;
|
||||
prompt = '';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn('ui:size-6 ui:text-muted-foreground ui:hover:text-primary', className)}
|
||||
aria-label="Generate content with AI"
|
||||
>
|
||||
<SparklesIcon class="ui:size-3.5" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content {align} sideOffset={8} class="ui:w-96 ui:p-4 ui:space-y-3" style="z-index: 251">
|
||||
<div class="ui:flex ui:items-center ui:gap-2">
|
||||
<p class="ui:text-sm ui:font-semibold">Generate Content</p>
|
||||
<InfoIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="ui:space-y-1.5">
|
||||
<p class="ui:text-sm ui:text-muted-foreground">Describe what this text is about</p>
|
||||
<Textarea
|
||||
bind:value={prompt}
|
||||
placeholder="e.g. a headline about the benefits of learning online"
|
||||
rows={3}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:gap-2">
|
||||
<Select.Root type="single" value={tone} onValueChange={handleToneChange}>
|
||||
<Select.Trigger size="sm" class="ui:w-auto">
|
||||
Tone: {toneOptions.find((t) => t.value === tone)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content style="z-index: 251">
|
||||
{#each toneOptions as option (option.value)}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" disabled={!prompt.trim() || loading} onclick={handleGenerate}>
|
||||
{loading ? 'Generating…' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AIGeneratePopover } from './ai-generate-popover.svelte';
|
||||
@@ -1,13 +1,13 @@
|
||||
<script module lang="ts">
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
/** Resolves `color-mix(in srgb, var(--primary) …)` for canvas dot fills (inherits `--primary` from this component’s DOM subtree). */
|
||||
/** Resolves `color-mix(in srgb, var(--primary) …%, transparent)` for canvas dot fills (inherits `--primary` from this component’s DOM subtree). */
|
||||
function primaryMixOnRoot(el: HTMLElement, primaryPercent: number): string {
|
||||
const probe = document.createElement('span');
|
||||
probe.setAttribute('aria-hidden', 'true');
|
||||
probe.style.cssText =
|
||||
'position:fixed;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;' +
|
||||
`color:var(--primary)`;
|
||||
`color:color-mix(in srgb, var(--primary) ${primaryPercent}%, transparent)`;
|
||||
el.appendChild(probe);
|
||||
const resolved = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
@@ -40,7 +40,13 @@
|
||||
glowRadius?: number;
|
||||
sparkle?: boolean;
|
||||
waveAmplitude?: number;
|
||||
/** When set, forces this glow color in all themes. Omit for theme-default cursor glow. */
|
||||
glowColor?: string;
|
||||
/**
|
||||
* Caps how opaque the SVG cursor glow becomes (multiplies motion-driven 0→1 fade).
|
||||
* Below `1` the dots always remain visible through the glow; use `1` for strongest cover.
|
||||
*/
|
||||
maxGlowOpacity?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
@@ -54,7 +60,8 @@
|
||||
glowRadius = 160,
|
||||
sparkle = false,
|
||||
waveAmplitude = 0,
|
||||
glowColor = '#ffffff',
|
||||
glowColor,
|
||||
maxGlowOpacity = 0.2,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
@@ -65,7 +72,7 @@
|
||||
|
||||
let dots: Dot[] = [];
|
||||
const mouse = { x: -9999, y: -9999, prevX: -9999, prevY: -9999, speed: 0 };
|
||||
let size = { w: 0, h: 0, offsetX: 0, offsetY: 0 };
|
||||
let size = { w: 0, h: 0 };
|
||||
let glowOpacity = 0;
|
||||
let engagement = 0;
|
||||
|
||||
@@ -78,7 +85,7 @@
|
||||
void bulgeStrength;
|
||||
void sparkle;
|
||||
void waveAmplitude;
|
||||
void glowColor;
|
||||
void maxGlowOpacity;
|
||||
|
||||
const currentGlow = glowEl;
|
||||
if (!canvas || !root) return;
|
||||
@@ -86,8 +93,8 @@
|
||||
const canvasEl: HTMLCanvasElement = canvas;
|
||||
const rootEl: HTMLDivElement = root;
|
||||
|
||||
const fillGradientFrom = primaryMixOnRoot(rootEl, 40);
|
||||
const fillGradientTo = primaryMixOnRoot(rootEl, 22);
|
||||
const fillGradientFrom = primaryMixOnRoot(rootEl, 70);
|
||||
const fillGradientTo = primaryMixOnRoot(rootEl, 45);
|
||||
|
||||
const ctx = canvasEl.getContext('2d', { alpha: true });
|
||||
|
||||
@@ -132,9 +139,7 @@
|
||||
|
||||
size = {
|
||||
w,
|
||||
h,
|
||||
offsetX: rect.left + window.scrollX,
|
||||
offsetY: rect.top + window.scrollY
|
||||
h
|
||||
};
|
||||
|
||||
buildDots(w, h);
|
||||
@@ -146,8 +151,9 @@
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
mouse.x = e.pageX - size.offsetX;
|
||||
mouse.y = e.pageY - size.offsetY;
|
||||
const rect = rootEl.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
function updateMouseSpeed() {
|
||||
@@ -161,6 +167,7 @@
|
||||
}
|
||||
|
||||
const speedInterval = setInterval(updateMouseSpeed, 20);
|
||||
const glowOpacityCap = Math.min(1, Math.max(0, maxGlowOpacity));
|
||||
|
||||
function tick() {
|
||||
frameCount++;
|
||||
@@ -176,7 +183,7 @@
|
||||
if (currentGlow) {
|
||||
currentGlow.setAttribute('cx', String(mouse.x));
|
||||
currentGlow.setAttribute('cy', String(mouse.y));
|
||||
currentGlow.style.opacity = String(glowOpacity);
|
||||
currentGlow.style.opacity = String(glowOpacity * glowOpacityCap);
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, w, h);
|
||||
@@ -265,13 +272,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={root} class={cn('relative h-full w-full', className)}>
|
||||
<div
|
||||
bind:this={root}
|
||||
class={cn(
|
||||
'relative h-full w-full',
|
||||
glowColor == null && 'ui:[--dot-field-glow:#fff] ui:dark:[--dot-field-glow:#0a0a0a]',
|
||||
className
|
||||
)}
|
||||
style={glowColor != null ? `--dot-field-glow: ${glowColor}` : undefined}
|
||||
>
|
||||
<canvas bind:this={canvas} class="absolute inset-0 h-full w-full"></canvas>
|
||||
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id={glowId}>
|
||||
<stop offset="0%" stop-color="#fff" />
|
||||
<stop offset="100%" stop-color="#fff" />
|
||||
<stop offset="0%" stop-color="var(--dot-field-glow, #fff)" />
|
||||
<stop offset="100%" stop-color="var(--dot-field-glow, #fff)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from './shinny-text';
|
||||
export * from './magic-card';
|
||||
export * from './gradual-spacing';
|
||||
export * from './square-card';
|
||||
export * from './waves';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Waves } from './waves.svelte';
|
||||
@@ -0,0 +1,358 @@
|
||||
<script module lang="ts">
|
||||
class Grad {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
|
||||
constructor(x: number, y: number, z: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
dot2(x: number, y: number): number {
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
}
|
||||
|
||||
class Noise {
|
||||
grad3: Grad[];
|
||||
p: number[];
|
||||
perm: number[] = new Array(512);
|
||||
gradP: Grad[] = new Array(512);
|
||||
|
||||
constructor(seed = 0) {
|
||||
this.grad3 = [
|
||||
new Grad(1, 1, 0),
|
||||
new Grad(-1, 1, 0),
|
||||
new Grad(1, -1, 0),
|
||||
new Grad(-1, -1, 0),
|
||||
new Grad(1, 0, 1),
|
||||
new Grad(-1, 0, 1),
|
||||
new Grad(1, 0, -1),
|
||||
new Grad(-1, 0, -1),
|
||||
new Grad(0, 1, 1),
|
||||
new Grad(0, -1, 1),
|
||||
new Grad(0, 1, -1),
|
||||
new Grad(0, -1, -1)
|
||||
];
|
||||
this.p = [
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37,
|
||||
240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177,
|
||||
33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146,
|
||||
158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25,
|
||||
63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100,
|
||||
109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206,
|
||||
59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153,
|
||||
101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246,
|
||||
97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192,
|
||||
214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114,
|
||||
67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180
|
||||
];
|
||||
this.seed(seed);
|
||||
}
|
||||
|
||||
seed(seed: number) {
|
||||
if (seed > 0 && seed < 1) seed *= 65536;
|
||||
seed = Math.floor(seed);
|
||||
if (seed < 256) seed |= seed << 8;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255);
|
||||
this.perm[i] = this.perm[i + 256] = v;
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
|
||||
}
|
||||
}
|
||||
|
||||
fade(t: number) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
|
||||
lerp(a: number, b: number, t: number) {
|
||||
return (1 - t) * a + t * b;
|
||||
}
|
||||
|
||||
perlin2(x: number, y: number): number {
|
||||
let X = Math.floor(x),
|
||||
Y = Math.floor(y);
|
||||
x -= X;
|
||||
y -= Y;
|
||||
X &= 255;
|
||||
Y &= 255;
|
||||
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
|
||||
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
|
||||
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
|
||||
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
|
||||
const u = this.fade(x);
|
||||
|
||||
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y));
|
||||
}
|
||||
}
|
||||
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
wave: { x: number; y: number };
|
||||
cursor: { x: number; y: number; vx: number; vy: number };
|
||||
};
|
||||
|
||||
/** Resolves `var(--primary)` for canvas strokes (inherits `--primary` from this component’s DOM subtree). */
|
||||
function primaryStrokeOnRoot(el: HTMLElement): string {
|
||||
const probe = document.createElement('span');
|
||||
probe.setAttribute('aria-hidden', 'true');
|
||||
probe.style.cssText =
|
||||
'position:fixed;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;' +
|
||||
`color:var(--primary)`;
|
||||
el.appendChild(probe);
|
||||
const resolved = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
return resolved || 'rgb(59, 130, 246)';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '../../../tools';
|
||||
|
||||
type Props = {
|
||||
/** Stroke color. Omit to use the theme primary color. */
|
||||
lineColor?: string;
|
||||
backgroundColor?: string;
|
||||
waveSpeedX?: number;
|
||||
waveSpeedY?: number;
|
||||
waveAmpX?: number;
|
||||
waveAmpY?: number;
|
||||
xGap?: number;
|
||||
yGap?: number;
|
||||
friction?: number;
|
||||
tension?: number;
|
||||
maxCursorMove?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
lineColor: lineColorProp,
|
||||
backgroundColor = 'transparent',
|
||||
waveSpeedX = 0.0125,
|
||||
waveSpeedY = 0.005,
|
||||
waveAmpX = 32,
|
||||
waveAmpY = 16,
|
||||
xGap = 10,
|
||||
yGap = 32,
|
||||
friction = 0.925,
|
||||
tension = 0.005,
|
||||
maxCursorMove = 100,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let root: HTMLDivElement | undefined;
|
||||
let canvasRef: HTMLCanvasElement | undefined;
|
||||
|
||||
$effect(() => {
|
||||
void waveSpeedX;
|
||||
void waveSpeedY;
|
||||
void waveAmpX;
|
||||
void waveAmpY;
|
||||
void xGap;
|
||||
void yGap;
|
||||
void friction;
|
||||
void tension;
|
||||
void maxCursorMove;
|
||||
void lineColorProp;
|
||||
|
||||
const canvasEl = canvasRef;
|
||||
const rootEl = root;
|
||||
|
||||
if (!canvasEl || !rootEl) return;
|
||||
|
||||
const strokeColor = lineColorProp ?? primaryStrokeOnRoot(rootEl);
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const noise = new Noise(Math.random());
|
||||
let lines: Point[][] = [];
|
||||
const bounding = { width: 0, height: 0 };
|
||||
const mouse = { x: -10, y: 0, lx: 0, ly: 0, sx: 0, sy: 0, v: 0, vs: 0, a: 0, set: false };
|
||||
|
||||
const ctx = canvasEl.getContext('2d', { alpha: true });
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
const context = ctx;
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const setSize = () => {
|
||||
const rect = rootEl.getBoundingClientRect();
|
||||
bounding.width = rect.width;
|
||||
bounding.height = rect.height;
|
||||
canvasEl.width = rect.width * dpr;
|
||||
canvasEl.height = rect.height * dpr;
|
||||
canvasEl.style.width = `${rect.width}px`;
|
||||
canvasEl.style.height = `${rect.height}px`;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
const setLines = () => {
|
||||
lines = [];
|
||||
const { width, height } = bounding;
|
||||
const oWidth = width + 200;
|
||||
const oHeight = height + 30;
|
||||
const totalLines = Math.ceil(oWidth / xGap);
|
||||
const totalPoints = Math.ceil(oHeight / yGap);
|
||||
const xStart = (width - xGap * totalLines) / 2;
|
||||
const yStart = (height - yGap * totalPoints) / 2;
|
||||
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const pts: Point[] = [];
|
||||
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
pts.push({
|
||||
x: xStart + xGap * i,
|
||||
y: yStart + yGap * j,
|
||||
wave: { x: 0, y: 0 },
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
lines.push(pts);
|
||||
}
|
||||
};
|
||||
|
||||
const movePoints = (time: number) => {
|
||||
lines.forEach((pts) => {
|
||||
pts.forEach((p) => {
|
||||
const move = noise.perlin2((p.x + time * waveSpeedX) * 0.002, (p.y + time * waveSpeedY) * 0.0015) * 12;
|
||||
p.wave.x = Math.cos(move) * waveAmpX;
|
||||
p.wave.y = Math.sin(move) * waveAmpY;
|
||||
const dx = p.x - mouse.sx;
|
||||
const dy = p.y - mouse.sy;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const l = Math.max(175, mouse.vs);
|
||||
|
||||
if (dist < l) {
|
||||
const s = 1 - dist / l;
|
||||
const f = Math.cos(dist * 0.001) * s;
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
}
|
||||
|
||||
p.cursor.vx += (0 - p.cursor.x) * tension;
|
||||
p.cursor.vy += (0 - p.cursor.y) * tension;
|
||||
p.cursor.vx *= friction;
|
||||
p.cursor.vy *= friction;
|
||||
p.cursor.x += p.cursor.vx * 2;
|
||||
p.cursor.y += p.cursor.vy * 2;
|
||||
p.cursor.x = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.x));
|
||||
p.cursor.y = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.y));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const moved = (point: Point, withCursor = true) => ({
|
||||
x: Math.round((point.x + point.wave.x + (withCursor ? point.cursor.x : 0)) * 10) / 10,
|
||||
y: Math.round((point.y + point.wave.y + (withCursor ? point.cursor.y : 0)) * 10) / 10
|
||||
});
|
||||
|
||||
const drawLines = () => {
|
||||
const { width, height } = bounding;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.beginPath();
|
||||
context.globalAlpha = 0.5;
|
||||
context.strokeStyle = strokeColor;
|
||||
|
||||
lines.forEach((points) => {
|
||||
let p1 = moved(points[0], false);
|
||||
context.moveTo(p1.x, p1.y);
|
||||
|
||||
points.forEach((p, idx) => {
|
||||
const isLast = idx === points.length - 1;
|
||||
p1 = moved(p, !isLast);
|
||||
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
|
||||
context.lineTo(p1.x, p1.y);
|
||||
|
||||
if (isLast) context.moveTo(p2.x, p2.y);
|
||||
});
|
||||
});
|
||||
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const tick = (t: number) => {
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
const dx = mouse.x - mouse.lx;
|
||||
const dy = mouse.y - mouse.ly;
|
||||
const d = Math.hypot(dx, dy);
|
||||
mouse.v = d;
|
||||
mouse.vs += (d - mouse.vs) * 0.1;
|
||||
mouse.vs = Math.min(100, mouse.vs);
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.a = Math.atan2(dy, dx);
|
||||
rootEl.style.setProperty('--waves-cursor-x', `${mouse.sx}px`);
|
||||
rootEl.style.setProperty('--waves-cursor-y', `${mouse.sy}px`);
|
||||
movePoints(t);
|
||||
drawLines();
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const updateMouse = (clientX: number, clientY: number) => {
|
||||
const rect = rootEl.getBoundingClientRect();
|
||||
mouse.x = clientX - rect.left;
|
||||
mouse.y = clientY - rect.top;
|
||||
|
||||
if (!mouse.set) {
|
||||
mouse.sx = mouse.x;
|
||||
mouse.sy = mouse.y;
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.set = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => updateMouse(e.clientX, e.clientY);
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
|
||||
if (touch) updateMouse(touch.clientX, touch.clientY);
|
||||
};
|
||||
|
||||
const doResize = () => {
|
||||
setSize();
|
||||
setLines();
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(doResize, 100);
|
||||
};
|
||||
|
||||
doResize();
|
||||
raf = requestAnimationFrame(tick);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(resizeTimer);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={root}
|
||||
class={cn('ui:absolute ui:left-0 ui:top-0 ui:h-full ui:w-full ui:overflow-hidden', className)}
|
||||
style:background-color={backgroundColor}
|
||||
>
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:left-0 ui:top-0 ui:h-2 ui:w-2 ui:rounded-full ui:bg-primary ui:opacity-70"
|
||||
style="transform: translate3d(calc(var(--waves-cursor-x, 0px) - 50%), calc(var(--waves-cursor-y, 0px) - 50%), 0); will-change: transform;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<canvas bind:this={canvasRef} class="ui:block ui:h-full ui:w-full"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { renderCertificateDocument, type CertificateDesign, type CertificateRenderData } from '@cio/certificates';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import MaximizeIcon from '@lucide/svelte/icons/maximize';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
type ZoomMode = number | 'fit';
|
||||
|
||||
interface Props {
|
||||
design: CertificateDesign;
|
||||
data: CertificateRenderData;
|
||||
zoom?: ZoomMode;
|
||||
showControls?: boolean;
|
||||
class?: string;
|
||||
/** The cert canvas is always 1100x780; tweak only if you need a different aspect. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
design,
|
||||
data,
|
||||
zoom = 'fit',
|
||||
showControls = false,
|
||||
class: className,
|
||||
width = 1100,
|
||||
height = 780
|
||||
}: Props = $props();
|
||||
|
||||
const MIN_SCALE = 0.18;
|
||||
const MAX_SCALE = 1.5;
|
||||
|
||||
let stageElement = $state<HTMLDivElement | null>(null);
|
||||
let manualScale = $state<number | null>(typeof zoom === 'number' ? clamp(zoom) : null);
|
||||
let fitScale = $state(0.4);
|
||||
|
||||
const srcdoc = $derived(renderCertificateDocument(design, data));
|
||||
|
||||
const isFit = $derived(zoom === 'fit' && manualScale == null);
|
||||
const scale = $derived(isFit ? fitScale : (manualScale ?? clamp(typeof zoom === 'number' ? zoom : 0.4)));
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.max(MIN_SCALE, Math.min(MAX_SCALE, value));
|
||||
}
|
||||
|
||||
function recomputeFitScale() {
|
||||
if (!stageElement) return;
|
||||
|
||||
const rect = stageElement.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const horizontal = rect.width / width;
|
||||
const vertical = rect.height / height;
|
||||
fitScale = clamp(Math.min(horizontal, vertical));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!stageElement || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const observer = new ResizeObserver(() => untrack(recomputeFitScale));
|
||||
observer.observe(stageElement);
|
||||
recomputeFitScale();
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function handleZoomIn() {
|
||||
manualScale = clamp((manualScale ?? fitScale) + 0.1);
|
||||
}
|
||||
function handleZoomOut() {
|
||||
manualScale = clamp((manualScale ?? fitScale) - 0.1);
|
||||
}
|
||||
function handleFit() {
|
||||
manualScale = null;
|
||||
recomputeFitScale();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={stageElement}
|
||||
class={cn('ui:relative ui:flex ui:h-full ui:w-full ui:items-center ui:justify-center ui:overflow-hidden', className)}
|
||||
>
|
||||
<div class="ui:origin-center" style:width="{width}px" style:height="{height}px" style:transform="scale({scale})">
|
||||
<iframe
|
||||
title="Certificate preview"
|
||||
sandbox="allow-same-origin"
|
||||
class="ui:h-full ui:w-full ui:rounded-sm ui:border-0 ui:shadow-[0_18px_40px_rgba(0,0,0,0.18),0_6px_12px_rgba(0,0,0,0.12)]"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
{srcdoc}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
{#if showControls}
|
||||
<div
|
||||
class="ui:absolute ui:bottom-3 ui:right-3 ui:flex ui:items-center ui:gap-1 ui:rounded-full ui:border ui:border-border ui:bg-card/95 ui:px-2 ui:py-1 ui:text-foreground ui:shadow-sm ui:backdrop-blur"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:size-7 ui:items-center ui:justify-center ui:rounded-full ui:transition-colors hover:ui:bg-muted"
|
||||
aria-label="Zoom out"
|
||||
onclick={handleZoomOut}
|
||||
>
|
||||
<MinusIcon class="ui:size-3.5" />
|
||||
</button>
|
||||
<span class="ui:min-w-10 ui:text-center ui:text-[10px] ui:font-medium ui:tracking-wider">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:size-7 ui:items-center ui:justify-center ui:rounded-full ui:transition-colors hover:ui:bg-muted"
|
||||
aria-label="Zoom in"
|
||||
onclick={handleZoomIn}
|
||||
>
|
||||
<PlusIcon class="ui:size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:size-7 ui:items-center ui:justify-center ui:rounded-full ui:transition-colors hover:ui:bg-muted"
|
||||
aria-label="Fit to viewport"
|
||||
onclick={handleFit}
|
||||
>
|
||||
<MaximizeIcon class="ui:size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
export { default as Preview } from './certificate-preview.svelte';
|
||||
export {
|
||||
ACCENT_COLORS,
|
||||
CERTIFICATE_TEMPLATE_IDS,
|
||||
CERTIFICATE_TEMPLATES,
|
||||
DEFAULT_ACCENT_COLOR,
|
||||
DEFAULT_CERTIFICATE_DESIGN,
|
||||
LEGACY_THEME_MAP,
|
||||
renderCertificate,
|
||||
renderCertificateDocument,
|
||||
resolveTemplateId,
|
||||
type AccentColor,
|
||||
type CertificateDesign,
|
||||
type CertificateRenderData,
|
||||
type CertificateRenderResult,
|
||||
type CertificateSignatory,
|
||||
type CertificateTemplateId,
|
||||
type CertificateTemplateMeta
|
||||
} from '@cio/certificates';
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../utils';
|
||||
|
||||
interface Props {
|
||||
/** Progress value from 0 to 100 */
|
||||
value: number;
|
||||
/** Size of the circular progress in pixels */
|
||||
size?: number;
|
||||
/** Stroke width of the progress ring */
|
||||
strokeWidth?: number;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
/** Color class for the progress stroke (e.g. 'stroke-primary') */
|
||||
progressClass?: string;
|
||||
/** Color class for the background track (e.g. 'stroke-muted') */
|
||||
trackClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value,
|
||||
size = 20,
|
||||
strokeWidth = 2,
|
||||
class: className = '',
|
||||
progressClass = 'stroke-primary',
|
||||
trackClass = 'stroke-muted'
|
||||
}: Props = $props();
|
||||
|
||||
const radius = $derived((size - strokeWidth) / 2);
|
||||
const circumference = $derived(2 * Math.PI * radius);
|
||||
const clampedValue = $derived(Math.max(0, Math.min(100, value)));
|
||||
const offset = $derived(circumference - (clampedValue / 100) * circumference);
|
||||
const center = $derived(size / 2);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
class={cn('shrink-0 -rotate-90', className)}
|
||||
aria-valuenow={clampedValue}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
role="progressbar"
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke-width={strokeWidth}
|
||||
class={cn('transition-all', trackClass)}
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={offset}
|
||||
stroke-linecap="round"
|
||||
class={cn('transition-all duration-300', progressClass)}
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,4 @@
|
||||
import CircularProgress from './circular-progress.svelte';
|
||||
|
||||
export { CircularProgress };
|
||||
export { CircularProgress as default };
|
||||
@@ -28,6 +28,7 @@
|
||||
onModelChange?: (model: AgentModelId) => void;
|
||||
onLockedModelSelect?: (model: AgentModelId) => void;
|
||||
onsubmit?: (payload: SubmitPayload) => void;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -39,10 +40,10 @@
|
||||
lockedModelIds = [],
|
||||
onModelChange,
|
||||
onLockedModelSelect,
|
||||
onsubmit
|
||||
onsubmit,
|
||||
prompt = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
let prompt = $state('');
|
||||
let level = $state('beginner');
|
||||
let selectedModel = $state<AgentModelId>(model);
|
||||
|
||||
@@ -86,7 +87,7 @@
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
onkeydown={handleKeydown}
|
||||
class="ui:w-full ui:resize-none ui:border-0! ui:focus-visible:ring-0! ui:focus-visible:ring-offset-0!"
|
||||
class="ui:w-full ui:resize-none ui:border-0! ui:focus-visible:ring-0! ui:focus-visible:ring-offset-0! ui:dark:bg-card!"
|
||||
/>
|
||||
|
||||
<div class="ui:mt-3 ui:flex ui:items-center ui:gap-2">
|
||||
@@ -113,7 +114,7 @@
|
||||
/>
|
||||
|
||||
<div class="ui:ml-auto">
|
||||
<Button variant="secondary" size="icon" disabled={!canSubmit} onclick={handleSubmit}>
|
||||
<Button variant="default" size="icon" disabled={!canSubmit} onclick={handleSubmit}>
|
||||
{#if loading}
|
||||
<LoaderCircleIcon class="ui:h-4 ui:w-4 ui:animate-spin" />
|
||||
{:else}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
variant?: EmptyVariant;
|
||||
layout?: 'block' | 'full-page';
|
||||
showLogo?: boolean;
|
||||
animatedBackground?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -40,6 +41,7 @@
|
||||
variant = 'default',
|
||||
layout = 'block',
|
||||
showLogo = false,
|
||||
animatedBackground = false,
|
||||
...restProps
|
||||
}: Props & Omit<ComponentProps<typeof EmptyBase.Root>, 'class' | 'children'> = $props();
|
||||
|
||||
@@ -47,6 +49,7 @@
|
||||
class: cn(emptyVariants({ variant }), className),
|
||||
'data-slot': 'empty',
|
||||
'data-variant': variant,
|
||||
animatedBackground,
|
||||
...restProps
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,11 +20,14 @@
|
||||
isDisabled?: boolean;
|
||||
min?: string | number | null | undefined;
|
||||
max?: string | number | null | undefined;
|
||||
step?: string | number | null | undefined;
|
||||
maxLength?: number;
|
||||
errorMessage?: string;
|
||||
helperMessage?: string;
|
||||
autoComplete?: boolean;
|
||||
onchange?: (e: InputOnChangeEvent) => void;
|
||||
onInputChange?: (e: InputOnChangeEvent) => void;
|
||||
labelAction?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -42,11 +45,14 @@
|
||||
isDisabled = false,
|
||||
min = undefined,
|
||||
max = undefined,
|
||||
step = undefined,
|
||||
maxLength = undefined,
|
||||
errorMessage = '',
|
||||
helperMessage = '',
|
||||
autoComplete = true,
|
||||
onchange = () => {},
|
||||
onInputChange = () => {}
|
||||
onInputChange = () => {},
|
||||
labelAction
|
||||
}: Props = $props();
|
||||
|
||||
let inputRef: HTMLInputElement | null = $state(null);
|
||||
@@ -70,12 +76,15 @@
|
||||
|
||||
<Field.Field class={className}>
|
||||
{#if label}
|
||||
<Field.Label for={name || 'input-field'} class={labelClassName}>
|
||||
{label}
|
||||
{#if isRequired}
|
||||
<span class="ui:text-red-700">*</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Label for={name || 'input-field'} class={labelClassName}>
|
||||
{label}
|
||||
{#if isRequired}
|
||||
<span class="ui:text-red-700">*</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
{@render labelAction?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Input
|
||||
@@ -88,6 +97,8 @@
|
||||
{name}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
maxlength={maxLength}
|
||||
required={isRequired}
|
||||
disabled={isDisabled}
|
||||
autocomplete={autoComplete ? 'on' : 'off'}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
let { source, options = {}, class: className = '' }: Props = $props();
|
||||
|
||||
const tracks = $derived(source.tracks ?? []);
|
||||
const isMuse = $derived.by(() => source.type === 'muse' && source.metadata?.svid);
|
||||
const isGoogleDrive = $derived(source.type === 'google_drive');
|
||||
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
|
||||
@@ -39,6 +40,6 @@
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
<PlyrPlayer src={source.url} {poster} {options} />
|
||||
<PlyrPlayer src={source.url} {poster} {options} {tracks} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import 'plyr/dist/plyr.css';
|
||||
import type { MediaPlayerOptions } from '../types';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
|
||||
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
|
||||
import { PLYR_DEFAULT_CONTROLS } from './constants';
|
||||
import type Plyr from 'plyr';
|
||||
@@ -10,13 +11,19 @@
|
||||
src: string;
|
||||
poster?: string;
|
||||
options?: MediaPlayerOptions;
|
||||
tracks?: VideoTextTrack[];
|
||||
}
|
||||
|
||||
let { src, poster, options = {} }: Props = $props();
|
||||
let { src, poster, options = {}, tracks = [] }: Props = $props();
|
||||
|
||||
let videoElement = $state<HTMLVideoElement | null>(null);
|
||||
let containerElement = $state<HTMLDivElement | null>(null);
|
||||
let playerInstance = $state<Plyr | null>(null);
|
||||
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
|
||||
let timeupdateCleanup: (() => void) | undefined;
|
||||
let firstPlayCleanup: (() => void) | undefined;
|
||||
let transcriptPanelCleanup: (() => void) | undefined;
|
||||
let hasFiredFirstPlay = false;
|
||||
|
||||
const playsinline = $derived(options.playsinline !== false);
|
||||
const maxHeight = $derived(options.maxHeight || '400px');
|
||||
@@ -26,34 +33,108 @@
|
||||
const isYouTube = $derived(isYoutubeUrl(src));
|
||||
const youtubeVideoId = $derived(isYouTube ? getYoutubeVideoId(src) : null);
|
||||
|
||||
onMount(async () => {
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
function attachTimeUpdates(player: Plyr): () => void {
|
||||
const handler = () => options.onTimeUpdate?.(player.currentTime);
|
||||
player.on('timeupdate', handler);
|
||||
return () => {
|
||||
player.off('timeupdate', handler);
|
||||
};
|
||||
}
|
||||
|
||||
if (isYouTube && youtubeVideoId && containerElement) {
|
||||
playerInstance = new PlyrConstructor(containerElement, {
|
||||
controls,
|
||||
autoplay,
|
||||
ratio: '16:9',
|
||||
iconUrl: '/plyr.svg',
|
||||
iconPrefix: 'plyr'
|
||||
});
|
||||
return;
|
||||
function attachFirstPlay(player: Plyr): () => void {
|
||||
const handler = () => {
|
||||
if (hasFiredFirstPlay) return;
|
||||
|
||||
hasFiredFirstPlay = true;
|
||||
options.onFirstPlay?.();
|
||||
};
|
||||
player.on('play', handler);
|
||||
return () => {
|
||||
player.off('play', handler);
|
||||
};
|
||||
}
|
||||
|
||||
function attachTranscriptPanelControl(
|
||||
player: Plyr,
|
||||
template: HTMLButtonElement,
|
||||
control: NonNullable<MediaPlayerOptions['transcriptPanelControl']>
|
||||
): () => void {
|
||||
type PlyrElements = { controls?: HTMLElement | null };
|
||||
const controls = (player as unknown as { elements: PlyrElements }).elements?.controls;
|
||||
|
||||
if (!controls) return () => {};
|
||||
|
||||
controls.querySelector('[data-cio-transcript-panel]')?.remove();
|
||||
|
||||
const button = template.cloneNode(true) as HTMLButtonElement;
|
||||
button.removeAttribute('hidden');
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
control.onClick();
|
||||
};
|
||||
button.addEventListener('click', onClick);
|
||||
|
||||
const fullscreen = controls.querySelector('[data-plyr="fullscreen"]');
|
||||
if (fullscreen) {
|
||||
fullscreen.insertAdjacentElement('beforebegin', button);
|
||||
} else {
|
||||
controls.appendChild(button);
|
||||
}
|
||||
|
||||
if (videoElement) {
|
||||
playerInstance = new PlyrConstructor(videoElement, {
|
||||
controls,
|
||||
autoplay,
|
||||
ratio: '16:9',
|
||||
iconUrl: '/plyr.svg',
|
||||
iconPrefix: 'plyr'
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
button.removeEventListener('click', onClick);
|
||||
button.remove();
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const PlyrModule = await import('plyr');
|
||||
const PlyrConstructor = PlyrModule.default;
|
||||
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
|
||||
|
||||
if (isYouTube && youtubeVideoId && containerElement) {
|
||||
playerInstance = new PlyrConstructor(containerElement, {
|
||||
controls,
|
||||
autoplay,
|
||||
ratio: '16:9',
|
||||
iconUrl: '/plyr.svg',
|
||||
iconPrefix: 'plyr'
|
||||
});
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoElement) {
|
||||
playerInstance = new PlyrConstructor(videoElement, {
|
||||
controls,
|
||||
autoplay,
|
||||
// Native files expose their own width/height — let Plyr fit to them instead of
|
||||
// forcing 16:9 and showing black letterbox bars around smaller/portrait videos.
|
||||
iconUrl: '/plyr.svg',
|
||||
iconPrefix: 'plyr'
|
||||
});
|
||||
timeupdateCleanup = attachTimeUpdates(playerInstance);
|
||||
firstPlayCleanup = attachFirstPlay(playerInstance);
|
||||
if (options.transcriptPanelControl && transcriptButtonElement) {
|
||||
transcriptPanelCleanup = attachTranscriptPanelControl(
|
||||
playerInstance,
|
||||
transcriptButtonElement,
|
||||
options.transcriptPanelControl
|
||||
);
|
||||
}
|
||||
options.onPlayerReady?.(playerInstance);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
timeupdateCleanup?.();
|
||||
firstPlayCleanup?.();
|
||||
transcriptPanelCleanup?.();
|
||||
if (!playerInstance) return;
|
||||
|
||||
try {
|
||||
@@ -72,10 +153,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:relative ui:rounded-md"
|
||||
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
|
||||
>
|
||||
<div class="ui:relative" style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};">
|
||||
{#if isYouTube && youtubeVideoId}
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
@@ -85,6 +163,47 @@
|
||||
></div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
|
||||
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player">
|
||||
{#each tracks as track (track.src + track.srclang)}
|
||||
<track
|
||||
kind={track.kind ?? 'captions'}
|
||||
src={track.src}
|
||||
srclang={track.srclang}
|
||||
label={track.label}
|
||||
default={track.default ?? false}
|
||||
/>
|
||||
{/each}
|
||||
</video>
|
||||
{/if}
|
||||
|
||||
{#if options.transcriptPanelControl}
|
||||
<button
|
||||
bind:this={transcriptButtonElement}
|
||||
type="button"
|
||||
class="plyr__control"
|
||||
data-cio-transcript-panel
|
||||
aria-label={options.transcriptPanelControl.label}
|
||||
hidden
|
||||
>
|
||||
<CaptionsIcon class="custom" aria-hidden="true" focusable="false" size={18} />
|
||||
<span class="plyr__tooltip" role="tooltip">{options.transcriptPanelControl.label}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Replace Plyr's default black letterbox background. Theme `muted` blends with the
|
||||
surrounding lesson area instead of looking like a TV bezel around smaller videos. */
|
||||
:global(.plyr) {
|
||||
--plyr-video-background: var(--muted);
|
||||
}
|
||||
:global(.plyr__control[data-cio-transcript-panel][hidden]) {
|
||||
display: none !important;
|
||||
}
|
||||
:global(.plyr__control[data-cio-transcript-panel] svg.custom) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type Plyr from 'plyr';
|
||||
|
||||
export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_drive';
|
||||
|
||||
export interface VideoTextTrack {
|
||||
src: string;
|
||||
srclang: string;
|
||||
label: string;
|
||||
default?: boolean;
|
||||
kind?: 'captions' | 'subtitles';
|
||||
}
|
||||
|
||||
export interface VideoSource {
|
||||
type: VideoType;
|
||||
url: string;
|
||||
@@ -8,6 +18,7 @@ export interface VideoSource {
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
};
|
||||
tracks?: VideoTextTrack[];
|
||||
}
|
||||
|
||||
export interface MediaPlayerOptions {
|
||||
@@ -19,4 +30,18 @@ export interface MediaPlayerOptions {
|
||||
height?: number | string;
|
||||
class?: string;
|
||||
playsinline?: boolean;
|
||||
/** Fires on HTML5 timeupdate (seconds). */
|
||||
onTimeUpdate?: (seconds: number) => void;
|
||||
/** Fires the first time playback transitions to playing in this player session. */
|
||||
onFirstPlay?: () => void;
|
||||
/** After Plyr is constructed (seek via `player.currentTime = n`). */
|
||||
onPlayerReady?: (player: Plyr) => void;
|
||||
/**
|
||||
* HTML5 playback only (not YouTube/embed): adds a control-bar button after Plyr inits.
|
||||
* Use for actions like reopening a transcript side panel without toggling on-video captions.
|
||||
*/
|
||||
transcriptPanelControl?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
interface IconProps {
|
||||
color?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { color = 'currentColor', size = 24, strokeWidth = 2, class: className = '' }: IconProps = $props();
|
||||
|
||||
let isShaking = $state(false);
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (isShaking) return;
|
||||
isShaking = true;
|
||||
setTimeout(() => {
|
||||
isShaking = false;
|
||||
}, 600);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className} aria-label="bot-off" role="img" onmouseenter={handleMouseEnter}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="bot-off"
|
||||
class:animate={isShaking}
|
||||
>
|
||||
<path d="M13.67 8H18a2 2 0 0 1 2 2v4.33" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M22 22 2 2" />
|
||||
<path d="M8 8H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 1.414-.586" />
|
||||
<path d="M9 13v2" />
|
||||
<path d="M9.67 4H12v2.33" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: inline-block;
|
||||
}
|
||||
.bot-off {
|
||||
overflow: visible;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
transform 0.6s ease-in-out,
|
||||
stroke 220ms ease-out;
|
||||
}
|
||||
|
||||
.bot-off.animate {
|
||||
animation: groupShake 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes groupShake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
16.67% {
|
||||
transform: translateX(-7%);
|
||||
}
|
||||
33.33% {
|
||||
transform: translateX(7%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-7%);
|
||||
}
|
||||
66.67% {
|
||||
transform: translateX(7%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
interface IconProps {
|
||||
color?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
class?: string;
|
||||
/** When provided, the bot's eyes track the cursor as it moves within this element. */
|
||||
trackElement?: HTMLElement | null;
|
||||
/** Maximum eye offset in SVG units. Larger = eyes move further when looking around. */
|
||||
maxEyeOffset?: number;
|
||||
/** Distance in px from the bot at which eye offset saturates. */
|
||||
trackRadius?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
color = 'currentColor',
|
||||
size = 24,
|
||||
strokeWidth = 2,
|
||||
class: className = '',
|
||||
trackElement = null,
|
||||
maxEyeOffset = 1.6,
|
||||
trackRadius = 240
|
||||
}: IconProps = $props();
|
||||
|
||||
let eyeY1 = $state(13);
|
||||
let eyeY2 = $state(15);
|
||||
let eyeOffsetX = $state(0);
|
||||
let eyeOffsetY = $state(0);
|
||||
let isBlinking = $state(false);
|
||||
let svgEl = $state<SVGSVGElement | null>(null);
|
||||
|
||||
function animateEyes(
|
||||
startY1: number,
|
||||
startY2: number,
|
||||
endY1: number,
|
||||
endY2: number,
|
||||
duration: number,
|
||||
delay = 0
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const startTime = performance.now();
|
||||
const step = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
||||
|
||||
eyeY1 = startY1 + (endY1 - startY1) * eased;
|
||||
eyeY2 = startY2 + (endY2 - startY2) * eased;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function blink() {
|
||||
if (isBlinking) return;
|
||||
isBlinking = true;
|
||||
animateEyes(13, 15, 14, 14, 250, 200).then(() =>
|
||||
animateEyes(14, 14, 13, 15, 250).then(() => {
|
||||
isBlinking = false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function handleTrackMove(event: MouseEvent) {
|
||||
if (!svgEl) return;
|
||||
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dx = event.clientX - cx;
|
||||
const dy = event.clientY - cy;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance === 0) {
|
||||
eyeOffsetX = 0;
|
||||
eyeOffsetY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const magnitude = Math.min(distance / trackRadius, 1);
|
||||
eyeOffsetX = (dx / distance) * maxEyeOffset * magnitude;
|
||||
eyeOffsetY = (dy / distance) * maxEyeOffset * magnitude;
|
||||
}
|
||||
|
||||
function handleTrackLeave() {
|
||||
eyeOffsetX = 0;
|
||||
eyeOffsetY = 0;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!trackElement) return;
|
||||
|
||||
trackElement.addEventListener('mousemove', handleTrackMove);
|
||||
trackElement.addEventListener('mouseleave', handleTrackLeave);
|
||||
return () => {
|
||||
trackElement.removeEventListener('mousemove', handleTrackMove);
|
||||
trackElement.removeEventListener('mouseleave', handleTrackLeave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={className} aria-label="bot" role="img" onmouseenter={blink}>
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="bot-icon"
|
||||
>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<line
|
||||
x1={15 + eyeOffsetX}
|
||||
y1={eyeY1 + eyeOffsetY}
|
||||
x2={15 + eyeOffsetX}
|
||||
y2={eyeY2 + eyeOffsetY}
|
||||
class="eye eye-right"
|
||||
/>
|
||||
<line
|
||||
x1={9 + eyeOffsetX}
|
||||
y1={eyeY1 + eyeOffsetY}
|
||||
x2={9 + eyeOffsetX}
|
||||
y2={eyeY2 + eyeOffsetY}
|
||||
class="eye eye-left"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: inline-block;
|
||||
}
|
||||
.bot-icon {
|
||||
overflow: visible;
|
||||
transition: stroke 220ms ease-out;
|
||||
}
|
||||
.eye {
|
||||
transition:
|
||||
transform 120ms ease-out,
|
||||
x 120ms ease-out,
|
||||
y 120ms ease-out;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
interface IconProps {
|
||||
color?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
isHovered?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
color = 'currentColor',
|
||||
size = 24,
|
||||
strokeWidth = 2,
|
||||
isHovered: isParentHovered = false,
|
||||
class: className = ''
|
||||
}: IconProps = $props();
|
||||
|
||||
let localMotion = $state(false);
|
||||
|
||||
const showMotion = $derived(isParentHovered || localMotion);
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (isParentHovered || localMotion) return;
|
||||
localMotion = true;
|
||||
|
||||
setTimeout(() => {
|
||||
localMotion = false;
|
||||
}, 700);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className} aria-label="chart-column" role="img" onmouseenter={handleMouseEnter}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="chart-column-icon"
|
||||
class:animate={showMotion}
|
||||
>
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" class="frame" />
|
||||
<path d="M18 17V9" class="column column-2" />
|
||||
<path d="M13 17V5" class="column column-1" />
|
||||
<path d="M8 17v-3" class="column column-0" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: inline-block;
|
||||
}
|
||||
.chart-column-icon {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.column {
|
||||
stroke-dasharray: 12;
|
||||
stroke-dashoffset: 0;
|
||||
transition:
|
||||
stroke-dashoffset 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-column-icon.animate .column {
|
||||
animation: columnAnimation 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.chart-column-icon.animate .column-0 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.chart-column-icon.animate .column-1 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.chart-column-icon.animate .column-2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@keyframes columnAnimation {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: 12;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,10 @@ export { default as AnalyticsIcon } from './analytics.svelte';
|
||||
export { default as ApiIcon } from './api.svelte';
|
||||
export { default as AttendanceIcon } from './attendance.svelte';
|
||||
export { default as AttachmentIcon } from './attachment.svelte';
|
||||
export { default as BotIcon } from './bot.svelte';
|
||||
export { default as BotOffIcon } from './bot-off.svelte';
|
||||
export { default as CertificateIcon } from './certificate.svelte';
|
||||
export { default as ChartColumnIcon } from './chart-column.svelte';
|
||||
export { default as CloseIcon } from './close.svelte';
|
||||
export { default as CommunityIcon } from './community.svelte';
|
||||
export { default as ContentIcon } from './content.svelte';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import BoldNav from './bold/nav.svelte';
|
||||
@@ -12,7 +13,7 @@
|
||||
import { DotPattern } from '../animation/dot-pattern';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import TagIcon from '@lucide/svelte/icons/tag';
|
||||
import { getPrimaryCourseTag } from './landing-page-utils';
|
||||
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
@@ -25,8 +26,8 @@
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
footerLinks,
|
||||
footerText
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function formatCurrency(cost?: number, currency = 'USD') {
|
||||
@@ -49,6 +50,7 @@
|
||||
</div>
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
||||
<BlurFade delay={0.1 * index} once={true}>
|
||||
<Card.Root
|
||||
@@ -82,6 +84,15 @@
|
||||
<span>{course.duration}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if courseTypeMeta}
|
||||
{@const TypeIcon = courseTypeMeta.icon}
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
|
||||
>
|
||||
<TypeIcon class={courseTypeMeta.iconClass} />
|
||||
<span>{courseTypeMeta.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if primaryTag}
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
|
||||
@@ -137,9 +148,11 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="bold" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="bold" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="bold" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="bold" />
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||
import { Button } from '../../../base/button';
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
@@ -11,36 +12,38 @@
|
||||
let { hero }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="ui:py-10 ui:px-6 ui:max-w-7xl ui:mx-auto ui:grid ui:lg:grid-cols-2 ui:gap-16 ui:items-center">
|
||||
<div>
|
||||
<BlurFade delay={0} once={true} class="ui:mb-6">
|
||||
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p class="ui:text-xl ui:text-muted-foreground ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:flex-wrap ui:gap-4">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20 ui:hover:-translate-y-1 ui:transition-all"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
{#if hero.image}
|
||||
<BlurFade delay={0.2} once={true} class="ui:relative ui:hidden ui:lg:block">
|
||||
<div
|
||||
class="ui:absolute ui:inset-0 ui:bg-primary/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
|
||||
></div>
|
||||
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</section>
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section class="ui:py-10 ui:px-6 ui:max-w-7xl ui:mx-auto ui:grid ui:lg:grid-cols-2 ui:gap-16 ui:items-center">
|
||||
<div>
|
||||
<BlurFade delay={0} once={true} class="ui:mb-6">
|
||||
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p class="ui:text-xl ui:text-muted-foreground ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:flex-wrap ui:gap-4">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20 ui:hover:-translate-y-1 ui:transition-all"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
{#if hero.image}
|
||||
<BlurFade delay={0.2} once={true} class="ui:relative ui:hidden ui:lg:block">
|
||||
<div
|
||||
class="ui:absolute ui:inset-0 ui:bg-primary/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
|
||||
></div>
|
||||
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
@@ -15,29 +16,34 @@
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="ui:sticky ui:top-0 ui:z-10 ui:bg-background/90 ui:backdrop-blur-xl ui:border-b ui:border-border/40">
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:py-5 ui:max-w-7xl ui:mx-auto">
|
||||
<a href="/" class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:items-center ui:gap-4">
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-8 ui:items-center">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-primary ui:transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} variant="outline" class="ui:rounded-xl ui:font-bold">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header class="ui:sticky ui:top-0 ui:z-10 ui:bg-background/90 ui:backdrop-blur-xl ui:border-b ui:border-border/40">
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:py-5 ui:max-w-7xl ui:mx-auto">
|
||||
<a
|
||||
href="/"
|
||||
class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:items-center ui:gap-4">
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-8 ui:items-center">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-primary ui:transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} variant="outline" class="ui:rounded-xl ui:font-bold">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -1,33 +1,77 @@
|
||||
<script lang="ts">
|
||||
import type { LandingPageCallout } from './types';
|
||||
import type { LandingPageCallout, OrgLandingPageTheme } from './types';
|
||||
import { Button } from '../../base/button';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import EditableLandingSection from './editable-section.svelte';
|
||||
|
||||
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: 'minimal' | 'bold' | 'classic' } =
|
||||
$props();
|
||||
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: OrgLandingPageTheme } = $props();
|
||||
|
||||
const sectionClasses: Record<string, string> = {
|
||||
minimal: 'ui:py-20 ui:px-6',
|
||||
bold: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
|
||||
classic: 'ui:py-20 ui:px-6 ui:bg-foreground ui:text-background'
|
||||
classic: 'ui:py-20 ui:px-6 ui:bg-foreground ui:text-background',
|
||||
saas: 'ui:py-24 ui:md:py-28 ui:px-6 ui:relative ui:overflow-hidden',
|
||||
tech: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
|
||||
studio:
|
||||
'ui:py-32 ui:px-6 ui:relative ui:overflow-hidden ui:bg-background ui:text-foreground ui:border-t ui:border-border',
|
||||
corporate: 'ui:py-20 ui:px-6 ui:bg-muted/40 ui:text-foreground ui:border-t ui:border-border',
|
||||
terminal: 'ui:py-20 ui:px-6 ui:border-t ui:border-[#1c1f28] ui:text-[#e9eaed] ui:bg-[#06070a]'
|
||||
};
|
||||
|
||||
const headingClasses: Record<string, string> = {
|
||||
minimal: 'ui:text-3xl ui:font-semibold ui:tracking-tight',
|
||||
bold: 'ui:text-4xl ui:lg:text-5xl ui:font-black ui:tracking-tighter',
|
||||
classic: 'ui:text-3xl ui:font-bold ui:tracking-tight'
|
||||
classic: 'ui:text-3xl ui:font-bold ui:tracking-tight',
|
||||
saas: 'ui:text-4xl ui:md:text-5xl ui:font-bold ui:tracking-tight ui:leading-[1.05]',
|
||||
tech: 'ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight',
|
||||
studio: 'ui:text-5xl ui:lg:text-6xl ui:font-semibold ui:tracking-tight ui:leading-[1.02]',
|
||||
corporate: 'ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight',
|
||||
terminal: 'ui:text-[22px] ui:font-semibold ui:tracking-tight'
|
||||
};
|
||||
|
||||
const descriptionClasses: Record<string, string> = {
|
||||
minimal: 'ui:text-lg ui:text-foreground/70',
|
||||
bold: 'ui:text-xl ui:text-background/70',
|
||||
classic: 'ui:text-lg ui:text-background/80'
|
||||
classic: 'ui:text-lg ui:text-background/80',
|
||||
saas: 'ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
|
||||
tech: 'ui:text-lg ui:text-background/70 ui:font-mono',
|
||||
studio: 'ui:text-base ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
|
||||
corporate: 'ui:text-base ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
|
||||
terminal: 'ui:text-sm ui:max-w-md ui:mx-auto ui:text-[#9da1ab]'
|
||||
};
|
||||
|
||||
const buttonClasses: Record<string, string> = {
|
||||
minimal: 'ui:rounded-full',
|
||||
bold: 'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20',
|
||||
classic: 'ui:px-8 ui:font-semibold'
|
||||
classic: 'ui:px-8 ui:font-semibold',
|
||||
saas: 'ui:rounded-full ui:px-7 ui:font-semibold ui:shadow-md ui:shadow-primary/20',
|
||||
tech: 'ui:rounded-none ui:px-8 ui:font-semibold',
|
||||
studio: 'ui:rounded-md ui:px-5 ui:font-medium ui:shadow-lg ui:shadow-primary/25',
|
||||
corporate: 'ui:rounded-none ui:px-8 ui:font-medium',
|
||||
terminal:
|
||||
'ui:rounded-full ui:px-4 ui:font-mono ui:text-[13px] ui:bg-[#0f1218] ui:text-[#e9eaed] ui:border ui:border-[#262a35] ui:hover:bg-[#14171f]'
|
||||
};
|
||||
|
||||
const eyebrowClasses: Record<string, string> = {
|
||||
minimal: '',
|
||||
bold: '',
|
||||
classic: '',
|
||||
saas: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-1',
|
||||
tech: 'ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-background/70 ui:mb-1',
|
||||
studio: 'ui:text-sm ui:text-muted-foreground ui:inline-flex ui:items-center ui:gap-2 ui:mb-2',
|
||||
corporate: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-1',
|
||||
terminal: ''
|
||||
};
|
||||
|
||||
const eyebrowLabels: Record<string, string | null> = {
|
||||
minimal: null,
|
||||
bold: null,
|
||||
classic: null,
|
||||
saas: 'Get started',
|
||||
tech: '// get started',
|
||||
studio: 'Ready when you are',
|
||||
corporate: 'Ready when you are',
|
||||
terminal: null
|
||||
};
|
||||
|
||||
const minimalBackground = `
|
||||
@@ -40,31 +84,63 @@
|
||||
</script>
|
||||
|
||||
{#if callout}
|
||||
<section
|
||||
class={sectionClasses[variant]}
|
||||
style={variant === 'minimal' ? `background: ${minimalBackground};` : undefined}
|
||||
>
|
||||
<div class="ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
|
||||
</BlurFade>
|
||||
|
||||
{#if callout.description}
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p class={descriptionClasses[variant]}>{callout.description}</p>
|
||||
</BlurFade>
|
||||
<EditableLandingSection sectionKey="callout">
|
||||
<section
|
||||
class={sectionClasses[variant]}
|
||||
style={variant === 'minimal' ? `background: ${minimalBackground};` : undefined}
|
||||
>
|
||||
{#if variant === 'studio'}
|
||||
<!-- soft radial glow at the bottom -->
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:left-1/2 ui:bottom-[-220px] ui:size-[800px] ui:-translate-x-1/2"
|
||||
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--primary) 28%, transparent) 0%, transparent 60%); filter: blur(40px);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
{#if variant === 'saas'}
|
||||
<!-- soft accent ring; the parent .frame already provides the dot grid + side lines -->
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:top-1/2 ui:left-1/2 ui:size-[640px] ui:-translate-x-1/2 ui:-translate-y-1/2 ui:opacity-60"
|
||||
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--primary) 14%, transparent) 0%, transparent 60%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<Button
|
||||
href={callout.action.href}
|
||||
size="lg"
|
||||
class={buttonClasses[variant]}
|
||||
variant={variant === 'bold' || variant === 'classic' ? 'secondary' : 'default'}
|
||||
>
|
||||
{callout.action.label}
|
||||
</Button>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
<div class="ui:relative ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||
{#if eyebrowLabels[variant]}
|
||||
<BlurFade delay={0} once={true}>
|
||||
{#if variant === 'studio'}
|
||||
<p class={eyebrowClasses[variant]}>
|
||||
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
|
||||
{eyebrowLabels[variant]}
|
||||
</p>
|
||||
{:else}
|
||||
<p class={eyebrowClasses[variant]}>{eyebrowLabels[variant]}</p>
|
||||
{/if}
|
||||
</BlurFade>
|
||||
{/if}
|
||||
|
||||
<BlurFade delay={0.05} once={true}>
|
||||
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
|
||||
</BlurFade>
|
||||
|
||||
{#if callout.description}
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p class={descriptionClasses[variant]}>{callout.description}</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<Button
|
||||
href={callout.action.href}
|
||||
size="lg"
|
||||
class={buttonClasses[variant]}
|
||||
variant={variant === 'bold' || variant === 'classic' || variant === 'tech' ? 'secondary' : 'default'}
|
||||
>
|
||||
{callout.action.label}
|
||||
</Button>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import ClassicNav from './classic/nav.svelte';
|
||||
@@ -10,8 +10,7 @@
|
||||
import * as Item from '../../base/item';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import { CourseCard } from '../course-card';
|
||||
import TagIcon from '@lucide/svelte/icons/tag';
|
||||
import { calcCourseDiscount, formatExerciseCountLabel, getPrimaryCourseTag } from './landing-page-utils';
|
||||
import { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from './landing-page-utils';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
let {
|
||||
@@ -25,11 +24,9 @@
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
footerLinks,
|
||||
footerText
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
const COURSE_TAG_ICON: Component = TagIcon;
|
||||
</script>
|
||||
|
||||
<div class="ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans">
|
||||
@@ -50,7 +47,7 @@
|
||||
|
||||
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
{@const pricingData = {
|
||||
cost: course.cost ?? 0,
|
||||
discount: course.metadata?.discount ?? 0,
|
||||
@@ -70,11 +67,11 @@
|
||||
bannerAlt={course.title}
|
||||
title={course.title}
|
||||
description={course.description}
|
||||
typeBadge={primaryTag
|
||||
typeBadge={courseTypeMeta
|
||||
? {
|
||||
label: primaryTag.name,
|
||||
icon: COURSE_TAG_ICON,
|
||||
iconClass: primaryTag.color ? '' : 'custom ui:text-primary'
|
||||
label: courseTypeMeta.label,
|
||||
icon: courseTypeMeta.icon,
|
||||
iconClass: courseTypeMeta.iconClass
|
||||
}
|
||||
: undefined}
|
||||
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:hover:shadow-md ui:transition-shadow')}
|
||||
@@ -146,9 +143,11 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="classic" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="classic" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="classic" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="classic" />
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import { BlurIn } from '../../animation/blurin';
|
||||
import { DotPattern } from '../../animation/dot-pattern';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
@@ -13,42 +14,44 @@
|
||||
let { hero }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="ui:relative ui:bg-foreground ui:text-background ui:overflow-hidden ui:py-24 ui:sm:py-32 ui:px-6 ui:lg:px-8"
|
||||
>
|
||||
{#if hero.image}
|
||||
<div class="ui:absolute ui:inset-0 ui:opacity-10">
|
||||
<img src={hero.image} alt="Background" class="ui:w-full ui:h-full ui:object-cover ui:grayscale" />
|
||||
</div>
|
||||
{/if}
|
||||
<DotPattern fillColor="rgb(255 255 255 / 0.35)" class="ui:opacity-[0.06]" />
|
||||
<div class="ui:relative ui:max-w-3xl ui:mx-auto ui:text-center">
|
||||
<BlurIn
|
||||
class="ui:text-4xl ui:sm:text-5xl ui:lg:text-6xl ui:font-bold ui:tracking-tight ui:mb-6 ui:leading-tight ui:drop-shadow-none"
|
||||
>
|
||||
{hero.heading}
|
||||
</BlurIn>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p class="ui:text-lg ui:sm:text-xl ui:text-background/80 ui:mb-10">{hero.subheading}</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:px-8 ui:font-semibold"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="classic"
|
||||
/>
|
||||
{/if}
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section
|
||||
class="ui:relative ui:bg-foreground ui:text-background ui:overflow-hidden ui:py-24 ui:sm:py-32 ui:px-6 ui:lg:px-8"
|
||||
>
|
||||
{#if hero.image}
|
||||
<div class="ui:absolute ui:inset-0 ui:opacity-10">
|
||||
<img src={hero.image} alt="Background" class="ui:w-full ui:h-full ui:object-cover ui:grayscale" />
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<DotPattern fillColor="rgb(255 255 255 / 0.35)" class="ui:opacity-[0.06]" />
|
||||
<div class="ui:relative ui:max-w-3xl ui:mx-auto ui:text-center">
|
||||
<BlurIn
|
||||
class="ui:text-4xl ui:sm:text-5xl ui:lg:text-6xl ui:font-bold ui:tracking-tight ui:mb-6 ui:leading-tight ui:drop-shadow-none"
|
||||
>
|
||||
{hero.heading}
|
||||
</BlurIn>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p class="ui:text-lg ui:sm:text-xl ui:text-background/80 ui:mb-10">{hero.subheading}</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:px-8 ui:font-semibold"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="classic"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
@@ -15,33 +16,35 @@
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="ui:bg-foreground ui:text-background">
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:lg:px-8 ui:h-20 ui:max-w-7xl ui:mx-auto">
|
||||
<a href="/" class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:items-center ui:gap-4">
|
||||
<nav class="ui:hidden ui:md:flex ui:space-x-8">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors"
|
||||
>{item.label}</a
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header class="ui:bg-foreground ui:text-background">
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:lg:px-8 ui:h-20 ui:max-w-7xl ui:mx-auto">
|
||||
<a href="/" class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:items-center ui:gap-4">
|
||||
<nav class="ui:hidden ui:md:flex ui:space-x-8">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if authAction}
|
||||
<Button
|
||||
href={authAction.href}
|
||||
variant="outline"
|
||||
class="ui:bg-transparent ui:text-background ui:border-background/40 ui:hover:bg-background/10 ui:hover:text-background ui:hover:border-background/60"
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if authAction}
|
||||
<Button
|
||||
href={authAction.href}
|
||||
variant="outline"
|
||||
class="ui:bg-transparent ui:text-background ui:border-background/40 ui:hover:bg-background/10 ui:hover:text-background ui:hover:border-background/60"
|
||||
>
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import CorporateNav from './corporate/nav.svelte';
|
||||
import CorporateHero from './corporate/hero.svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
logoUrl,
|
||||
navItems,
|
||||
authAction,
|
||||
hero,
|
||||
courses,
|
||||
hasMoreCourses = false,
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function formatCurrency(cost?: number, currency = 'USD') {
|
||||
if (!cost) return 'Free';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
||||
<main>
|
||||
<CorporateHero {orgName} {hero}>
|
||||
{#snippet navigation()}
|
||||
<CorporateNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||
{/snippet}
|
||||
</CorporateHero>
|
||||
|
||||
{#if courses.length > 0}
|
||||
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-10">
|
||||
<div>
|
||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-3">
|
||||
Catalog
|
||||
</p>
|
||||
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0 ui:max-w-xl">
|
||||
Programs your teams are taking
|
||||
</h2>
|
||||
</div>
|
||||
{#if hasMoreCourses}
|
||||
<Button
|
||||
href={disableCourseLinks ? undefined : '/courses'}
|
||||
variant="outline"
|
||||
class="ui:rounded-none ui:font-medium"
|
||||
disabled={disableCourseLinks}
|
||||
>
|
||||
Browse catalog →
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-border"
|
||||
>
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
<BlurFade delay={0.06 * index} once={true}>
|
||||
<a
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
class="ui:flex ui:flex-col ui:h-full ui:p-6 ui:gap-2 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:no-underline {disableCourseLinks
|
||||
? 'ui:cursor-default'
|
||||
: 'ui:hover:bg-muted/30'}"
|
||||
aria-disabled={disableCourseLinks}
|
||||
tabindex={disableCourseLinks ? -1 : undefined}
|
||||
>
|
||||
{#if courseTypeMeta}
|
||||
<span
|
||||
class="ui:text-[11px] ui:font-semibold ui:tracking-widest ui:uppercase ui:text-muted-foreground ui:mb-1"
|
||||
>
|
||||
{courseTypeMeta.label}
|
||||
</span>
|
||||
{/if}
|
||||
<h3 class="ui:text-lg ui:font-semibold ui:tracking-tight ui:leading-snug ui:m-0">
|
||||
{course.title}
|
||||
</h3>
|
||||
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0">
|
||||
{course.description}
|
||||
</p>
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-3 ui:border-t ui:border-border">
|
||||
<span class="ui:text-sm ui:font-semibold">
|
||||
{course.price || formatCurrency(course.cost, course.currency)}
|
||||
</span>
|
||||
<span class="ui:text-sm ui:text-foreground ui:underline ui:underline-offset-4" aria-hidden="true">
|
||||
Enroll →
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="corporate" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="corporate" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="corporate" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from '../types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||
import { Button } from '../../../base/button';
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: OrgLandingPageProps['orgName'];
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
navigation: Snippet;
|
||||
}
|
||||
|
||||
let { orgName, hero, navigation }: Props = $props();
|
||||
|
||||
const stats = $derived(hero.stats ?? []);
|
||||
</script>
|
||||
|
||||
{@render navigation()}
|
||||
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section class="ui:px-6 ui:pt-12 ui:pb-24 ui:md:pt-16 ui:md:pb-28">
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<div class="ui:flex ui:items-center ui:gap-4 ui:pb-5 ui:mb-10 ui:border-b ui:border-border">
|
||||
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-foreground">
|
||||
{orgName}
|
||||
</span>
|
||||
<span class="ui:h-px ui:flex-1 ui:bg-border" aria-hidden="true"></span>
|
||||
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-muted-foreground">
|
||||
Learning Report
|
||||
</span>
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-12 ui:gap-x-8 ui:gap-y-12">
|
||||
<div class="ui:md:col-span-7">
|
||||
<BlurFade delay={0.08} once={true}>
|
||||
<h1
|
||||
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-semibold ui:tracking-tight ui:leading-[1.05] ui:mb-6 ui:text-foreground"
|
||||
>
|
||||
{hero.heading}
|
||||
</h1>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.18} once={true}>
|
||||
<p class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
||||
{hero.subheading}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.28} once={true}>
|
||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
class="ui:rounded-none ui:px-6 ui:font-medium"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="corporate"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
|
||||
{#if stats.length > 0}
|
||||
<aside class="ui:md:col-span-4 ui:md:col-start-9 ui:md:border-l ui:md:border-border ui:md:pl-8">
|
||||
<BlurFade delay={0.32} once={true}>
|
||||
<p
|
||||
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-muted-foreground ui:mb-6"
|
||||
>
|
||||
At a glance
|
||||
</p>
|
||||
|
||||
<dl class="ui:flex ui:flex-col">
|
||||
{#each stats as stat, index (`${stat.label}-${index}`)}
|
||||
<div
|
||||
class="ui:flex ui:items-baseline ui:justify-between ui:gap-4 ui:py-4 ui:border-t ui:border-border ui:first:border-t-0"
|
||||
>
|
||||
<div class="ui:flex ui:items-baseline ui:gap-3 ui:min-w-0">
|
||||
<span class="ui:text-[11px] ui:font-mono ui:text-muted-foreground ui:tabular-nums">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<dt class="ui:text-sm ui:text-foreground ui:truncate">
|
||||
{stat.label}
|
||||
</dt>
|
||||
</div>
|
||||
<dd
|
||||
class="ui:text-2xl ui:md:text-3xl ui:font-semibold ui:tracking-tight ui:tabular-nums ui:text-foreground ui:m-0"
|
||||
>
|
||||
{stat.value}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</BlurFade>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
navItems: NavItem[];
|
||||
authAction?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-background/95 ui:backdrop-blur-md ui:border-b ui:border-border">
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto ui:px-6">
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:h-14">
|
||||
<a
|
||||
href="/"
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:no-underline"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
|
||||
{#if navItems.length > 0}
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-7">
|
||||
{#each navItems as item (item.href + item.label)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-3">
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} size="sm" class="ui:rounded-none ui:px-4 ui:font-medium">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
/**
|
||||
* Sections in the org landing page that the editor allows you to click in the preview
|
||||
* to open. Matches the keys used by the editor's section list.
|
||||
*/
|
||||
export type LandingSectionKey = 'hero' | 'navigation' | 'callout' | 'embed' | 'links' | 'footer';
|
||||
|
||||
export interface LandingPageEditContext {
|
||||
/** Reactive read of the currently selected section (or null if none). */
|
||||
selectedKey: () => LandingSectionKey | null;
|
||||
/** Sets the selected section. Pass `null` to deselect. */
|
||||
selectKey: (key: LandingSectionKey | null) => void;
|
||||
/** Human-readable label for the section cap (typically translated). */
|
||||
labelFor: (key: LandingSectionKey) => string;
|
||||
/** Icon component rendered inside the section cap. */
|
||||
iconFor: (key: LandingSectionKey) => Component;
|
||||
}
|
||||
|
||||
const KEY = Symbol('orgLandingPageEdit');
|
||||
|
||||
export function setLandingPageEditContext(ctx: LandingPageEditContext): void {
|
||||
setContext(KEY, ctx);
|
||||
}
|
||||
|
||||
export function getLandingPageEditContext(): LandingPageEditContext | null {
|
||||
return getContext<LandingPageEditContext | null>(KEY) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getLandingPageEditContext, type LandingSectionKey } from './edit-context';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
interface Props {
|
||||
sectionKey: LandingSectionKey;
|
||||
/** Extra classes applied to the wrapper (only used when edit context is active). */
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { sectionKey, class: className, children }: Props = $props();
|
||||
|
||||
const ctx = getLandingPageEditContext();
|
||||
const selected = $derived(ctx?.selectedKey() === sectionKey);
|
||||
const Icon = $derived(ctx?.iconFor(sectionKey));
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!ctx) return;
|
||||
event.stopPropagation();
|
||||
ctx.selectKey(sectionKey);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!ctx) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ctx.selectKey(sectionKey);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !ctx}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div
|
||||
data-landing-section={sectionKey}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-pressed={selected}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
class={cn(
|
||||
'ui:relative ui:outline-none ui:cursor-pointer ui:transition-shadow',
|
||||
selected
|
||||
? 'ui:ring-2 ui:ring-primary ui:ring-inset'
|
||||
: 'ui:hover:ring-2 ui:hover:ring-primary/40 ui:hover:ring-inset',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#if selected}
|
||||
<div
|
||||
class="ui:absolute ui:top-0 ui:left-0 ui:z-30 ui:-translate-y-full ui:flex ui:items-center ui:gap-1.5 ui:rounded-t-md ui:bg-primary ui:px-2 ui:py-1 ui:text-xs ui:font-medium ui:text-primary-foreground ui:shadow-sm ui:pointer-events-none"
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon class="ui:size-3.5" />
|
||||
{/if}
|
||||
<span>{ctx.labelFor(sectionKey)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,35 +2,95 @@
|
||||
import type { OrgLandingPageTheme, LandingPageEmbed } from './types';
|
||||
import SecondaryActionButton from './secondary-action-button.svelte';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import EditableLandingSection from './editable-section.svelte';
|
||||
|
||||
let { embed, variant = 'minimal' }: { embed?: LandingPageEmbed; variant?: OrgLandingPageTheme } = $props();
|
||||
</script>
|
||||
|
||||
{#if embed}
|
||||
<section class="ui:py-12 ui:px-6 ui:md:px-8">
|
||||
<div class="ui:mx-auto ui:flex ui:max-w-7xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
|
||||
</BlurFade>
|
||||
{#if embed.description?.trim()}
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-muted-foreground">
|
||||
{embed.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
||||
<BlurFade delay={0.2} once={true}>
|
||||
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{#if embed.code}
|
||||
<BlurFade delay={0.28} once={true}>
|
||||
<div class="ui:flex ui:w-full ui:justify-center">
|
||||
{@html embed.code}
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<EditableLandingSection sectionKey="embed">
|
||||
{#if variant === 'terminal'}
|
||||
<section class="ui:py-24 ui:px-6" style="border-top: 1px solid #1c1f28; background: #06070a;">
|
||||
<div class="ui:mx-auto ui:max-w-[1120px]">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<p
|
||||
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
||||
style="color: var(--primary);"
|
||||
>
|
||||
<span
|
||||
class="ui:size-1.5 ui:rounded-full"
|
||||
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
|
||||
></span>
|
||||
Embed
|
||||
</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.08} once={true}>
|
||||
<h3
|
||||
class="ui:text-[36px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-4"
|
||||
style="color: #e9eaed; line-height: 1.1; letter-spacing: -0.025em;"
|
||||
>
|
||||
{embed.title}
|
||||
</h3>
|
||||
</BlurFade>
|
||||
{#if embed.description?.trim()}
|
||||
<BlurFade delay={0.16} once={true}>
|
||||
<p class="ui:max-w-xl ui:m-0 ui:mb-6 ui:text-[15px]" style="color: #9da1ab;">
|
||||
{embed.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
||||
<BlurFade delay={0.22} once={true}>
|
||||
<div class="ui:mb-8">
|
||||
<SecondaryActionButton
|
||||
href={embed.secondaryAction.href}
|
||||
label={embed.secondaryAction.label}
|
||||
{variant}
|
||||
/>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{#if embed.code}
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div
|
||||
class="ui:rounded-xl ui:overflow-hidden ui:max-w-[820px]"
|
||||
style="border: 1px solid #262a35; background: #0f1218; box-shadow: 0 30px 90px -30px rgba(0,0,0,0.7);"
|
||||
>
|
||||
<div class="ui:p-5 ui:overflow-x-auto" style="color: #e9eaed;">
|
||||
{@html embed.code}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="ui:py-12 ui:px-6 ui:md:px-8">
|
||||
<div class="ui:mx-auto ui:flex ui:max-w-7xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
|
||||
</BlurFade>
|
||||
{#if embed.description?.trim()}
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-muted-foreground">
|
||||
{embed.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
|
||||
<BlurFade delay={0.2} once={true}>
|
||||
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{#if embed.code}
|
||||
<BlurFade delay={0.28} once={true}>
|
||||
<div class="ui:flex ui:w-full ui:justify-center">
|
||||
{@html embed.code}
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</EditableLandingSection>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { FooterSocialPlatform } from './types';
|
||||
import FacebookIcon from '@lucide/svelte/icons/facebook';
|
||||
import GithubIcon from '@lucide/svelte/icons/github';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import InstagramIcon from '@lucide/svelte/icons/instagram';
|
||||
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
|
||||
import YoutubeIcon from '@lucide/svelte/icons/youtube';
|
||||
|
||||
interface Props {
|
||||
platform: FooterSocialPlatform;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { platform, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if platform === 'instagram'}
|
||||
<InstagramIcon class={className} aria-hidden="true" />
|
||||
{:else if platform === 'linkedin'}
|
||||
<LinkedinIcon class={className} aria-hidden="true" />
|
||||
{:else if platform === 'facebook'}
|
||||
<FacebookIcon class={className} aria-hidden="true" />
|
||||
{:else if platform === 'youtube'}
|
||||
<YoutubeIcon class={className} aria-hidden="true" />
|
||||
{:else if platform === 'github'}
|
||||
<GithubIcon class={className} aria-hidden="true" />
|
||||
{:else if platform === 'website'}
|
||||
<GlobeIcon class={className} aria-hidden="true" />
|
||||
{:else if platform === 'x'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if platform === 'tiktok'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FooterSocialPlatform } from './types';
|
||||
|
||||
const KNOWN_PLATFORMS = new Set<FooterSocialPlatform>([
|
||||
'instagram',
|
||||
'x',
|
||||
'linkedin',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'github',
|
||||
'tiktok',
|
||||
'website'
|
||||
]);
|
||||
|
||||
/** Normalize persisted platform string (e.g. from JSON) to a known platform. */
|
||||
export function resolveFooterSocialPlatform(value: unknown): FooterSocialPlatform {
|
||||
if (typeof value === 'string' && KNOWN_PLATFORMS.has(value as FooterSocialPlatform)) {
|
||||
return value as FooterSocialPlatform;
|
||||
}
|
||||
|
||||
return 'website';
|
||||
}
|
||||
|
||||
/**
|
||||
* If a footer link label matches a known social brand, treat it as a social URL instead of a bottom link.
|
||||
*/
|
||||
export function labelMatchesSocialPlatform(label: string): FooterSocialPlatform | null {
|
||||
const compact = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, '');
|
||||
|
||||
if (compact === 'facebook') return 'facebook';
|
||||
if (compact === 'instagram') return 'instagram';
|
||||
if (compact === 'twitter' || compact === 'x') return 'x';
|
||||
if (compact === 'linkedin') return 'linkedin';
|
||||
if (compact === 'youtube') return 'youtube';
|
||||
if (compact === 'github' || compact === 'git') return 'github';
|
||||
if (compact === 'tiktok') return 'tiktok';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const FOOTER_SOCIAL_PLATFORMS = [
|
||||
'instagram',
|
||||
'x',
|
||||
'linkedin',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'github',
|
||||
'tiktok',
|
||||
'website'
|
||||
] as const satisfies readonly FooterSocialPlatform[];
|
||||
@@ -1,14 +1,38 @@
|
||||
export { default as MinimalLandingPage } from './minimal.svelte';
|
||||
export { default as BoldLandingPage } from './bold.svelte';
|
||||
export { default as ClassicLandingPage } from './classic.svelte';
|
||||
export { default as SaasLandingPage } from './saas.svelte';
|
||||
export { default as TechLandingPage } from './tech.svelte';
|
||||
export { default as StudioLandingPage } from './studio.svelte';
|
||||
export { default as CorporateLandingPage } from './corporate.svelte';
|
||||
export { default as TerminalLandingPage } from './terminal.svelte';
|
||||
export { default as BoldLandingNav } from './bold/nav.svelte';
|
||||
export { default as BoldLandingHero } from './bold/hero.svelte';
|
||||
export { default as ClassicLandingNav } from './classic/nav.svelte';
|
||||
export { default as ClassicLandingHero } from './classic/hero.svelte';
|
||||
export { default as MinimalLandingNav } from './minimal/nav.svelte';
|
||||
export { default as MinimalLandingHero } from './minimal/hero.svelte';
|
||||
export { default as SaasLandingNav } from './saas/nav.svelte';
|
||||
export { default as SaasLandingHero } from './saas/hero.svelte';
|
||||
export { default as TechLandingNav } from './tech/nav.svelte';
|
||||
export { default as TechLandingHero } from './tech/hero.svelte';
|
||||
export { default as StudioLandingNav } from './studio/nav.svelte';
|
||||
export { default as StudioLandingHero } from './studio/hero.svelte';
|
||||
export { default as CorporateLandingNav } from './corporate/nav.svelte';
|
||||
export { default as CorporateLandingHero } from './corporate/hero.svelte';
|
||||
export { default as TerminalLandingNav } from './terminal/nav.svelte';
|
||||
export { default as TerminalLandingHero } from './terminal/hero.svelte';
|
||||
export { default as OrgLandingPageEmbed } from './embed.svelte';
|
||||
export { default as OrgLandingPageCallout } from './callout.svelte';
|
||||
export { default as OrgLandingPageLinks } from './links.svelte';
|
||||
export { default as OrgLandingPageFooter } from './landing-page-footer.svelte';
|
||||
export { default as SecondaryActionButton } from './secondary-action-button.svelte';
|
||||
export { default as EditableLandingSection } from './editable-section.svelte';
|
||||
export {
|
||||
setLandingPageEditContext,
|
||||
getLandingPageEditContext,
|
||||
type LandingSectionKey,
|
||||
type LandingPageEditContext
|
||||
} from './edit-context';
|
||||
export * from './landing-page-link-icons';
|
||||
export * from './types';
|
||||
|
||||
@@ -1,52 +1,172 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps, OrgLandingPageTheme } from './types';
|
||||
import type { OrgLandingPageFooterConfig, OrgLandingPageTheme } from './types';
|
||||
import { getFooterTokens } from './landing-page-footer.tokens';
|
||||
import FooterSocialIcon from './footer-social-icon.svelte';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import EditableLandingSection from './editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
footerLinks: OrgLandingPageProps['footerLinks'];
|
||||
footerText: string;
|
||||
footer: OrgLandingPageFooterConfig;
|
||||
variant: OrgLandingPageTheme;
|
||||
}
|
||||
|
||||
let { orgName, logoUrl, footerLinks, footerText, variant }: Props = $props();
|
||||
let { orgName, logoUrl, footer, variant }: Props = $props();
|
||||
|
||||
const tokens = $derived(getFooterTokens(variant));
|
||||
|
||||
const bottom = $derived(footer.bottom);
|
||||
const bottomLinks = $derived(bottom?.links ?? []);
|
||||
const bottomText = $derived((bottom?.text ?? '').trim());
|
||||
|
||||
function isGridMode(config: OrgLandingPageFooterConfig): boolean {
|
||||
const tagline = (config.brand.tagline ?? '').trim();
|
||||
const copyright = (config.brand.copyright ?? '').trim();
|
||||
|
||||
return config.columns.length > 0 || config.brand.socials.length > 0 || Boolean(tagline) || Boolean(copyright);
|
||||
}
|
||||
|
||||
const showGrid = $derived(isGridMode(footer));
|
||||
|
||||
const showBottomRowInGrid = $derived(bottomText.length > 0 || bottomLinks.length > 0);
|
||||
|
||||
function hasCta(column: OrgLandingPageFooterConfig['columns'][number]): boolean {
|
||||
const cta = column.cta;
|
||||
if (!cta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(cta.label.trim() && cta.href.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if variant === 'minimal'}
|
||||
<footer
|
||||
class="ui:py-12 ui:px-8 ui:max-w-[1200px] ui:mx-auto ui:border-t ui:border-border/50 ui:flex ui:flex-col ui:md:flex-row ui:items-center ui:justify-between ui:gap-4 ui:text-sm ui:text-muted-foreground"
|
||||
>
|
||||
<a href="/" class="ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-8">
|
||||
{#each footerLinks as link}
|
||||
<a href={link.href} class="ui:hover:text-foreground ui:transition-colors">{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
<p>{footerText}</p>
|
||||
</footer>
|
||||
{:else}
|
||||
<footer class="ui:bg-foreground ui:text-background/60 ui:py-16 ui:px-6">
|
||||
<div class="ui:max-w-7xl ui:mx-auto ui:flex ui:flex-col ui:md:flex-row ui:items-center ui:justify-between ui:gap-8">
|
||||
<a
|
||||
href="/"
|
||||
class="ui:font-black ui:text-2xl ui:text-background ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto ui:grayscale ui:brightness-200" />
|
||||
<EditableLandingSection sectionKey="footer">
|
||||
<footer class={tokens.root}>
|
||||
{#if variant === 'terminal'}
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:inset-x-[-10%] ui:bottom-[-50%] ui:h-[380px]"
|
||||
style="
|
||||
background:
|
||||
radial-gradient(ellipse 60% 80% at 30% 50%, color-mix(in oklab, #f59e0b 35%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 60% at 70% 60%, color-mix(in oklab, #ef4444 30%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 40% 50% at 50% 80%, color-mix(in oklab, #c026d3 22%, transparent) 0%, transparent 60%);
|
||||
filter: blur(40px);
|
||||
"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="ui:relative {tokens.container}">
|
||||
{#if showGrid}
|
||||
{#snippet gridInner()}
|
||||
<div
|
||||
class="ui:grid ui:grid-cols-1 ui:gap-y-10 ui:gap-x-8 ui:sm:grid-cols-2 ui:md:gap-x-12 ui:md:gap-y-12 ui:md:grid-cols-[minmax(0,1.4fr)_repeat(auto-fit,minmax(140px,1fr))]"
|
||||
>
|
||||
<div class="ui:sm:col-span-2 ui:md:col-span-1 ui:min-w-0">
|
||||
<a href="/" class={tokens.brandName}>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="" class={tokens.logoImg} />
|
||||
{/if}
|
||||
<span class="ui:break-words">{orgName}</span>
|
||||
</a>
|
||||
{#if footer.brand.tagline?.trim()}
|
||||
<p class={tokens.brandTagline}>{footer.brand.tagline}</p>
|
||||
{/if}
|
||||
{#if footer.brand.socials.length > 0}
|
||||
<ul class={tokens.socialWrap}>
|
||||
{#each footer.brand.socials as social (social.href + social.platform)}
|
||||
<li class="ui:list-none">
|
||||
<a
|
||||
href={social.href}
|
||||
class={tokens.socialIcon}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label={social.platform}
|
||||
>
|
||||
<FooterSocialIcon platform={social.platform} class="ui:size-4 ui:md:size-[1.125rem]" />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if footer.brand.copyright?.trim()}
|
||||
<p class="{tokens.brandTagline} ui:mt-3">{footer.brand.copyright}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each footer.columns as column (column.id)}
|
||||
<div class="ui:min-w-0">
|
||||
{#if column.heading.trim()}
|
||||
<h3 class={tokens.columnHeading}>{column.heading}</h3>
|
||||
{/if}
|
||||
<ul class="ui:list-none ui:m-0 ui:p-0 ui:space-y-1">
|
||||
{#each column.links as link (link.id)}
|
||||
<li>
|
||||
<a href={link.href} class={tokens.columnLink}>{link.label}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hasCta(column)}
|
||||
<a href={column.cta!.href} class={tokens.columnCta}>
|
||||
{column.cta!.label}
|
||||
<ArrowRightIcon class="ui:size-4 ui:shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if variant === 'minimal' || variant === 'bold' || variant === 'saas' || variant === 'tech' || variant === 'studio' || variant === 'corporate' || variant === 'terminal'}
|
||||
<BlurFade delay={0} once={true}>
|
||||
{#snippet children()}
|
||||
{@render gridInner()}
|
||||
{/snippet}
|
||||
</BlurFade>
|
||||
{:else}
|
||||
{@render gridInner()}
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-8">
|
||||
{#each footerLinks as link}
|
||||
<a href={link.href} class="ui:hover:text-background ui:font-medium ui:transition-colors">{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="ui:text-sm">{footerText}</p>
|
||||
|
||||
{#if showBottomRowInGrid}
|
||||
{#if variant === 'classic' && tokens.bottomHairline}
|
||||
<div class={tokens.bottomHairline} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class={tokens.bottomRow}>
|
||||
{#if bottomText}
|
||||
<p class={tokens.bottomText}>{bottomText}</p>
|
||||
{/if}
|
||||
{#if bottomLinks.length > 0}
|
||||
<div class={tokens.bottomLinksWrap}>
|
||||
{#each bottomLinks as link (link.id)}
|
||||
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class={tokens.compactRow}>
|
||||
<a href="/" class={tokens.brandName}>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="" class={tokens.logoImg} />
|
||||
{/if}
|
||||
<span class="ui:break-words">{orgName}</span>
|
||||
</a>
|
||||
{#if bottomLinks.length > 0}
|
||||
<div
|
||||
class="ui:flex ui:flex-wrap ui:justify-start ui:gap-6 ui:md:justify-center ui:md:gap-8 ui:w-full ui:md:w-auto"
|
||||
>
|
||||
{#each bottomLinks as link (link.id)}
|
||||
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bottomText}
|
||||
<p class="{tokens.bottomText} ui:w-full ui:md:w-auto ui:text-left ui:md:text-right">{bottomText}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { OrgLandingPageTheme } from './types';
|
||||
|
||||
/** Class strings per footer region; markup reads these so themes stay consistent with nav/hero tokens. */
|
||||
export type FooterRegionTokens = {
|
||||
root: string;
|
||||
container: string;
|
||||
compactRow: string;
|
||||
bottomRow: string;
|
||||
bottomHairline?: string;
|
||||
brandName: string;
|
||||
brandTagline: string;
|
||||
columnHeading: string;
|
||||
columnLink: string;
|
||||
columnCta: string;
|
||||
socialWrap: string;
|
||||
socialIcon: string;
|
||||
bottomText: string;
|
||||
bottomLinksWrap: string;
|
||||
bottomLink: string;
|
||||
logoImg: string;
|
||||
};
|
||||
|
||||
export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionTokens {
|
||||
if (variant === 'minimal') {
|
||||
return {
|
||||
root: 'ui:border-t ui:border-border/50 ui:py-12 ui:px-4 ui:sm:px-6 ui:md:px-8 ui:md:py-16',
|
||||
container: 'ui:max-w-[1200px] ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-border/50 ui:text-sm ui:text-muted-foreground',
|
||||
brandName:
|
||||
'ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-2',
|
||||
columnHeading: 'ui:text-sm ui:font-semibold ui:text-foreground ui:mb-4',
|
||||
columnLink:
|
||||
'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-semibold ui:text-foreground ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto'
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'tech') {
|
||||
return {
|
||||
root: 'ui:bg-foreground ui:text-background ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20',
|
||||
container: 'ui:max-w-7xl ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-background/15 ui:text-xs ui:text-background/55 ui:font-mono',
|
||||
brandName:
|
||||
'ui:font-bold ui:text-xl ui:tracking-tight ui:text-background ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-background/60 ui:break-words ui:mt-3 ui:max-w-[260px]',
|
||||
columnHeading:
|
||||
'ui:text-[11px] ui:font-mono ui:font-semibold ui:uppercase ui:tracking-widest ui:text-background/60 ui:mb-4',
|
||||
columnLink:
|
||||
'ui:block ui:text-sm ui:text-background/75 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-semibold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:border ui:border-background/15 ui:bg-background/5 ui:text-background/75 ui:hover:text-background ui:hover:bg-background/10 ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-xs ui:font-mono ui:text-background/65 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'studio') {
|
||||
return {
|
||||
root: 'ui:bg-background ui:text-foreground ui:border-t ui:border-border ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20',
|
||||
container: 'ui:max-w-[1080px] ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground',
|
||||
brandName:
|
||||
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed',
|
||||
columnHeading: 'ui:text-xs ui:font-medium ui:text-muted-foreground ui:mb-3',
|
||||
columnLink:
|
||||
'ui:block ui:text-[13px] ui:text-foreground ui:hover:text-muted-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-medium ui:text-primary ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:size-7 ui:rounded-md ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'terminal') {
|
||||
return {
|
||||
root: 'ui:relative ui:overflow-hidden ui:pt-16 ui:pb-0 ui:px-4 ui:sm:px-6 ui:md:pt-20 ui:bg-[#06070a]',
|
||||
container: 'ui:max-w-[1120px] ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[#9da1ab]',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-14 ui:pt-6 ui:pb-6 ui:border-t ui:border-[#1c1f28] ui:text-xs ui:text-[#61656f]',
|
||||
brandName:
|
||||
'ui:font-semibold ui:text-base ui:text-[#e9eaed] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-[#9da1ab] ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed',
|
||||
columnHeading: 'ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:text-[#61656f] ui:mb-4',
|
||||
columnLink:
|
||||
'ui:block ui:text-[13.5px] ui:text-[#9da1ab] ui:hover:text-[#e9eaed] ui:transition-colors ui:break-words ui:py-1 ui:md:py-0 ui:no-underline',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-medium ui:text-[#e9eaed] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:text-[#9da1ab] ui:hover:text-[#e9eaed] ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-xs ui:text-[#61656f] ui:hover:text-[#e9eaed] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0 ui:no-underline',
|
||||
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'corporate') {
|
||||
return {
|
||||
root: 'ui:bg-background ui:border-t ui:border-border ui:py-12 ui:px-4 ui:sm:px-6 ui:md:py-16',
|
||||
container: 'ui:max-w-[1120px] ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground',
|
||||
brandName:
|
||||
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed',
|
||||
columnHeading: 'ui:text-xs ui:font-semibold ui:tracking-wider ui:text-foreground ui:mb-3 ui:uppercase',
|
||||
columnLink:
|
||||
'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-medium ui:text-foreground ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline ui:underline ui:underline-offset-4',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'saas') {
|
||||
return {
|
||||
root: 'ui:bg-background ui:border-t ui:border-border ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-16',
|
||||
container: 'ui:max-w-[1180px] ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground',
|
||||
brandName:
|
||||
'ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px]',
|
||||
columnHeading: 'ui:text-sm ui:font-bold ui:text-foreground ui:mb-3',
|
||||
columnLink:
|
||||
'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-semibold ui:text-primary ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-border ui:bg-muted/40 ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'bold') {
|
||||
return {
|
||||
root: 'ui:bg-foreground ui:text-background/60 ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-20',
|
||||
container: 'ui:max-w-7xl ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-background/15 ui:text-xs ui:text-background/50',
|
||||
brandName:
|
||||
'ui:font-black ui:text-2xl ui:tracking-tighter ui:text-background ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-background/60 ui:break-words ui:mt-2',
|
||||
columnHeading: 'ui:text-xs ui:font-bold ui:uppercase ui:tracking-widest ui:text-background ui:mb-4',
|
||||
columnLink:
|
||||
'ui:block ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-bold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-background/70 ui:hover:text-background ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-xs ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto ui:grayscale ui:brightness-200'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
root: 'ui:bg-foreground ui:text-background ui:py-12 ui:px-4 ui:sm:px-6 ui:lg:px-8 ui:md:py-16',
|
||||
container: 'ui:max-w-7xl ui:mx-auto',
|
||||
compactRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm',
|
||||
bottomRow:
|
||||
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:text-sm ui:text-background/70',
|
||||
bottomHairline: 'ui:h-px ui:w-16 ui:md:w-24 ui:bg-background/30 ui:mx-auto ui:mb-6',
|
||||
brandName:
|
||||
'ui:text-xl ui:font-bold ui:tracking-wide ui:text-background ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
|
||||
brandTagline: 'ui:text-sm ui:text-background/70 ui:break-words ui:mt-2',
|
||||
columnHeading: 'ui:text-sm ui:font-semibold ui:text-background ui:mb-4',
|
||||
columnLink:
|
||||
'ui:block ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
|
||||
columnCta:
|
||||
'ui:text-sm ui:font-semibold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline',
|
||||
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
|
||||
socialIcon:
|
||||
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-background/70 ui:hover:text-background ui:transition-colors ui:no-underline',
|
||||
bottomText: 'ui:break-words',
|
||||
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
|
||||
bottomLink:
|
||||
'ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
|
||||
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Component, ComponentProps } from 'svelte';
|
||||
import type { LandingPageLinkIcon } from './types';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import CalendarIcon from '@lucide/svelte/icons/calendar';
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
|
||||
import LifeBuoyIcon from '@lucide/svelte/icons/life-buoy';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import MessageCircleIcon from '@lucide/svelte/icons/message-circle';
|
||||
import NewspaperIcon from '@lucide/svelte/icons/newspaper';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
|
||||
export type LinkIconComponent = Component<ComponentProps<typeof HelpCircleIcon>>;
|
||||
|
||||
/** Every key in `LandingPageLinkIcon` must map to a Lucide icon component. */
|
||||
export const landingPageLinkIconMap: Record<LandingPageLinkIcon, LinkIconComponent> = {
|
||||
'help-circle': HelpCircleIcon,
|
||||
'life-buoy': LifeBuoyIcon,
|
||||
'book-open': BookOpenIcon,
|
||||
video: VideoIcon,
|
||||
users: UsersIcon,
|
||||
'message-circle': MessageCircleIcon,
|
||||
newspaper: NewspaperIcon,
|
||||
rocket: RocketIcon,
|
||||
calendar: CalendarIcon,
|
||||
mail: MailIcon
|
||||
};
|
||||
|
||||
const FALLBACK_ICON: LandingPageLinkIcon = 'help-circle';
|
||||
|
||||
export function resolveLandingPageLinkIcon(value: unknown): LandingPageLinkIcon {
|
||||
if (typeof value === 'string' && value in landingPageLinkIconMap) {
|
||||
return value as LandingPageLinkIcon;
|
||||
}
|
||||
|
||||
return FALLBACK_ICON;
|
||||
}
|
||||
@@ -1,9 +1,64 @@
|
||||
import type { Component } from 'svelte';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import type { CourseItem } from './types';
|
||||
|
||||
export function getPrimaryCourseTag(course: CourseItem) {
|
||||
return course.tags?.[0];
|
||||
}
|
||||
|
||||
/** Label + icon for course `type` (COURSE_TYPE enum) on org landing cards. */
|
||||
export function getCourseTypeLandingMeta(
|
||||
course: CourseItem
|
||||
): { label: string; icon: Component; iconClass?: string } | undefined {
|
||||
const raw = course.type?.trim();
|
||||
if (!raw) return undefined;
|
||||
|
||||
const known: Record<string, { label: string; icon: Component; iconClass?: string }> = {
|
||||
LIVE_CLASS: {
|
||||
label: 'Live class',
|
||||
icon: CircleDotIcon,
|
||||
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-red-700'
|
||||
},
|
||||
SELF_PACED: {
|
||||
label: 'Self-paced',
|
||||
icon: UserIcon,
|
||||
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-primary'
|
||||
},
|
||||
COMPLIANCE: {
|
||||
label: 'Compliance',
|
||||
icon: ShieldCheckIcon,
|
||||
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-emerald-700'
|
||||
},
|
||||
PUBLIC: {
|
||||
label: 'Public',
|
||||
icon: GlobeIcon,
|
||||
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-primary'
|
||||
}
|
||||
};
|
||||
|
||||
if (known[raw]) {
|
||||
return known[raw];
|
||||
}
|
||||
|
||||
const label = raw
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
label: label || raw,
|
||||
icon: GraduationCapIcon,
|
||||
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-muted-foreground'
|
||||
};
|
||||
}
|
||||
|
||||
/** Plain copy for org landing templates (no dashboard i18n). */
|
||||
export function formatExerciseCountLabel(count: number) {
|
||||
if (count === 1) return '1 exercise';
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
import type { LandingPageLinks, OrgLandingPageTheme } from './types';
|
||||
import { landingPageLinkIconMap } from './landing-page-link-icons';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import { BorderBeam } from '../animation/border-beam';
|
||||
import { DotPattern } from '../animation/dot-pattern';
|
||||
import * as Card from '../../base/card';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import EditableLandingSection from './editable-section.svelte';
|
||||
|
||||
let { links, variant = 'minimal' }: { links?: LandingPageLinks; variant?: OrgLandingPageTheme } = $props();
|
||||
|
||||
function linkDisplayHint(href: string) {
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
try {
|
||||
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
const url = new URL(withProtocol);
|
||||
return url.hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return trimmed.length > 48 ? `${trimmed.slice(0, 45)}…` : trimmed;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if links && links.cards.length > 0}
|
||||
<EditableLandingSection sectionKey="links">
|
||||
{#if variant === 'minimal'}
|
||||
<section class="ui:py-20 ui:px-4">
|
||||
<div class="ui:max-w-[1200px] ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h2 class="ui:text-2xl ui:font-semibold {links.description?.trim() ? 'ui:mb-3' : 'ui:mb-10'}">
|
||||
{links.heading}
|
||||
</h2>
|
||||
</BlurFade>
|
||||
{#if links.description?.trim()}
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<p class="ui:text-base ui:text-muted-foreground ui:max-w-2xl ui:mb-10">
|
||||
{links.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6">
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
<BlurFade delay={0.1 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:group ui:block ui:h-full ui:no-underline"
|
||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||
>
|
||||
<Card.Root
|
||||
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 ui:transition-colors ui:hover:border-border"
|
||||
>
|
||||
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1 ui:h-full">
|
||||
<IconComponent class="ui:size-6 ui:text-foreground ui:mb-6" aria-hidden="true" />
|
||||
<Card.Title class="ui:text-xl ui:font-normal ui:mb-3">{card.title}</Card.Title>
|
||||
<Card.Description
|
||||
class="ui:text-base ui:leading-relaxed ui:text-muted-foreground ui:line-clamp-3 ui:mb-6"
|
||||
>
|
||||
{card.description}
|
||||
</Card.Description>
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-muted-foreground ui:mt-auto"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="ui:truncate">{linkDisplayHint(card.href)}</span>
|
||||
<ArrowRightIcon
|
||||
class="ui:size-4 ui:shrink-0 ui:transition-transform ui:group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if variant === 'tech'}
|
||||
<section class="ui:relative ui:py-24 ui:px-6 ui:bg-primary ui:text-primary-foreground ui:overflow-hidden">
|
||||
<DotPattern class="ui:opacity-[0.08]" />
|
||||
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:mb-3">{links.heading}</h2>
|
||||
</BlurFade>
|
||||
{#if links.description?.trim()}
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<p class="ui:text-lg ui:text-primary-foreground/80 ui:max-w-2xl ui:mb-14 ui:font-mono">
|
||||
{links.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{:else}
|
||||
<div class="ui:mb-14" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div
|
||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border ui:border-primary-foreground/20"
|
||||
>
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
<BlurFade delay={0.08 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:group ui:block ui:h-full ui:p-8 ui:border-r ui:border-b ui:border-primary-foreground/15 ui:transition-colors ui:hover:bg-primary-foreground/5 ui:no-underline ui:text-primary-foreground"
|
||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||
>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-primary-foreground/15 ui:text-primary-foreground ui:font-mono ui:font-bold ui:mb-5"
|
||||
>
|
||||
<IconComponent class="ui:size-4 custom" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:mb-2">{card.title}</h3>
|
||||
<p class="ui:text-sm ui:text-primary-foreground/70 ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||
{card.description}
|
||||
</p>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if variant === 'studio'}
|
||||
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border ui:bg-background">
|
||||
<div class="ui:max-w-[1080px] ui:mx-auto">
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:mb-12 ui:items-end">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<div>
|
||||
<p class="ui:text-sm ui:text-muted-foreground ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
|
||||
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
|
||||
Resources
|
||||
</p>
|
||||
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">{links.heading}</h2>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{#if links.description?.trim()}
|
||||
<BlurFade delay={0.1} once={true}>
|
||||
<p class="ui:text-base ui:text-muted-foreground ui:leading-relaxed ui:m-0">
|
||||
{links.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-4 ui:gap-3">
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
<BlurFade delay={0.06 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:group ui:block ui:h-full ui:p-6 ui:bg-card ui:border ui:border-border ui:rounded-xl ui:transition-colors ui:hover:border-foreground/30 ui:hover:bg-muted/30 ui:no-underline"
|
||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||
>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-lg ui:bg-muted ui:border ui:border-border ui:text-primary ui:mb-3"
|
||||
>
|
||||
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 class="ui:text-sm ui:font-medium ui:tracking-tight ui:mb-1.5 ui:text-foreground">{card.title}</h3>
|
||||
<p class="ui:text-[13px] ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||
{card.description}
|
||||
</p>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if variant === 'terminal'}
|
||||
{@const cardCount = links.cards.length}
|
||||
{@const lgColsClass =
|
||||
cardCount >= 4
|
||||
? 'ui:lg:grid-cols-4'
|
||||
: cardCount === 3
|
||||
? 'ui:lg:grid-cols-3'
|
||||
: cardCount === 2
|
||||
? 'ui:lg:grid-cols-2'
|
||||
: 'ui:lg:grid-cols-1'}
|
||||
{@const mdColsClass = cardCount === 1 ? 'ui:md:grid-cols-1' : 'ui:md:grid-cols-2'}
|
||||
<section
|
||||
class="ui:py-24 ui:px-6"
|
||||
style="border-top: 1px solid #1c1f28; background:
|
||||
radial-gradient(ellipse 60% 100% at 50% 0%, color-mix(in oklab, var(--primary) 6%, transparent) 0%, transparent 60%),
|
||||
#06070a;"
|
||||
>
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<p
|
||||
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
||||
style="color: var(--primary);"
|
||||
>
|
||||
<span
|
||||
class="ui:size-1.5 ui:rounded-full"
|
||||
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
|
||||
></span>
|
||||
Resources
|
||||
</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.08} once={true}>
|
||||
<h2
|
||||
class="ui:text-[40px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10"
|
||||
style="color: #e9eaed; letter-spacing: -0.025em; line-height: 1.08;"
|
||||
>
|
||||
{links.heading}
|
||||
</h2>
|
||||
</BlurFade>
|
||||
<div
|
||||
class="ui:grid ui:grid-cols-1 {mdColsClass} {lgColsClass} ui:overflow-hidden ui:rounded-[14px] ui:gap-[1px]"
|
||||
style="border: 1px solid #1c1f28; background: #1c1f28;"
|
||||
>
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
<BlurFade delay={0.06 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:flex ui:flex-col ui:gap-3 ui:p-7 ui:no-underline ui:h-full ui:transition-colors"
|
||||
style="background: #0f1218; color: #e9eaed;"
|
||||
onmouseenter={(e) => (e.currentTarget.style.background = '#14171f')}
|
||||
onmouseleave={(e) => (e.currentTarget.style.background = '#0f1218')}
|
||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||
>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:rounded-lg"
|
||||
style="border: 1px solid #262a35; background: #14171f; color: var(--primary);"
|
||||
>
|
||||
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 class="ui:text-sm ui:font-semibold ui:m-0" style="color: #e9eaed;">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p class="ui:text-[13px] ui:m-0 ui:line-clamp-3" style="color: #9da1ab; line-height: 1.5;">
|
||||
{card.description}
|
||||
</p>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if variant === 'corporate'}
|
||||
<section class="ui:py-20 ui:px-6 ui:bg-muted/30 ui:border-t ui:border-b ui:border-border">
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-3">
|
||||
Resources
|
||||
</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.1} once={true}>
|
||||
<h2 class="ui:text-3xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10">{links.heading}</h2>
|
||||
</BlurFade>
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-border">
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
<BlurFade delay={0.06 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:flex ui:gap-5 ui:items-start ui:p-7 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:hover:bg-muted/40 ui:no-underline ui:h-full"
|
||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||
>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-foreground ui:text-background ui:flex-shrink-0"
|
||||
>
|
||||
<IconComponent class="ui:size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ui:min-w-0">
|
||||
<h3 class="ui:text-base ui:font-semibold ui:tracking-tight ui:mb-1.5 ui:m-0">{card.title}</h3>
|
||||
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if variant === 'saas'}
|
||||
<section class="ui:py-20 ui:px-6 ui:bg-muted/40">
|
||||
<div class="ui:max-w-[1180px] ui:mx-auto">
|
||||
<div class="ui:text-center ui:max-w-xl ui:mx-auto ui:mb-12">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">
|
||||
Resources
|
||||
</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.1} once={true}>
|
||||
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight ui:m-0">
|
||||
{links.heading}
|
||||
</h2>
|
||||
</BlurFade>
|
||||
{#if links.description?.trim()}
|
||||
<BlurFade delay={0.18} once={true}>
|
||||
<p class="ui:text-base ui:text-muted-foreground ui:mt-3 ui:leading-relaxed">
|
||||
{links.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[1px] ui:bg-border ui:border ui:border-border"
|
||||
>
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
<BlurFade delay={0.08 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:bg-background ui:p-7 ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:transition-colors ui:hover:bg-muted/40 ui:no-underline ui:h-full"
|
||||
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
|
||||
>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-10 ui:rounded-xl ui:bg-primary/10 ui:text-primary ui:mb-1"
|
||||
>
|
||||
<IconComponent class="ui:size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 class="ui:text-base ui:font-bold ui:tracking-tight ui:m-0">{card.title}</h3>
|
||||
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
|
||||
{card.description}
|
||||
</p>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if variant === 'bold'}
|
||||
<section class="ui:relative ui:py-20 ui:px-6 ui:bg-background ui:overflow-hidden">
|
||||
<DotPattern class="ui:opacity-[0.10]" />
|
||||
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight ui:mb-3">{links.heading}</h2>
|
||||
</BlurFade>
|
||||
{#if links.description?.trim()}
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<p class="ui:text-lg ui:text-muted-foreground ui:max-w-2xl ui:mb-12">{links.description}</p>
|
||||
</BlurFade>
|
||||
{:else}
|
||||
<div class="ui:mb-12" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8">
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
{@const visitLabel = links.boldVisitLabel?.trim()}
|
||||
<BlurFade delay={0.1 * index} once={true}>
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:group ui:block ui:h-full ui:no-underline"
|
||||
aria-label={`${card.title}${visitLabel ? ` — ${visitLabel}` : ''} (opens in new tab)`}
|
||||
>
|
||||
<Card.Root
|
||||
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:bg-background ui:border-border/50 ui:p-8 ui:h-full ui:flex ui:flex-col ui:transition-all ui:duration-300 ui:hover:shadow-xl ui:hover:shadow-primary/5"
|
||||
>
|
||||
<BorderBeam
|
||||
size={120}
|
||||
duration={8}
|
||||
colorFrom="var(--primary)"
|
||||
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
|
||||
/>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:justify-center ui:size-12 ui:rounded-2xl ui:bg-primary/10 ui:text-primary ui:mb-6"
|
||||
>
|
||||
<IconComponent class="ui:size-6" aria-hidden="true" />
|
||||
</div>
|
||||
<Card.Title class="ui:text-2xl ui:font-bold ui:tracking-tight ui:mb-3">{card.title}</Card.Title>
|
||||
<Card.Description class="ui:text-base ui:text-muted-foreground ui:line-clamp-3 ui:mb-6">
|
||||
{card.description}
|
||||
</Card.Description>
|
||||
<div class="ui:mt-auto">
|
||||
<span
|
||||
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:text-sm ui:font-bold ui:text-foreground ui:bg-muted ui:px-3 ui:py-1.5 ui:rounded-lg ui:transition-colors ui:group-hover:bg-primary ui:group-hover:text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if visitLabel}
|
||||
{visitLabel}
|
||||
{/if}
|
||||
<ArrowRightIcon class="ui:size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</Card.Root>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
|
||||
<div class="ui:text-center">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">{links.heading}</h2>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-border ui:mx-auto"></div>
|
||||
</BlurFade>
|
||||
{#if links.description?.trim()}
|
||||
<BlurFade delay={0.22} once={true}>
|
||||
<p class="ui:mt-6 ui:text-center ui:text-muted-foreground ui:max-w-2xl ui:mx-auto">
|
||||
{links.description}
|
||||
</p>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="ui:mt-10 ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center"
|
||||
>
|
||||
{#each links.cards as card, index (index)}
|
||||
{@const IconComponent = landingPageLinkIconMap[card.icon]}
|
||||
{@const learnMoreLabel = links.classicLearnMoreLabel?.trim()}
|
||||
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:max-w-md">
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="ui:group ui:block ui:h-full ui:no-underline"
|
||||
aria-label={`${card.title}${learnMoreLabel ? ` — ${learnMoreLabel}` : ''} (opens in new tab)`}
|
||||
>
|
||||
<Card.Root
|
||||
class="ui:w-full ui:rounded-xl ui:border-border ui:bg-background ui:p-6 ui:h-full ui:flex ui:flex-col ui:hover:shadow-md ui:transition-shadow"
|
||||
>
|
||||
<div class="ui:flex ui:items-center ui:justify-center ui:size-12 ui:rounded-full ui:bg-muted ui:mb-4">
|
||||
<IconComponent class="ui:size-5 ui:text-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<Card.Title class="ui:text-xl ui:font-semibold ui:mb-2">{card.title}</Card.Title>
|
||||
<Card.Description class="ui:text-sm ui:text-muted-foreground ui:line-clamp-3 ui:mb-4">
|
||||
{card.description}
|
||||
</Card.Description>
|
||||
<div
|
||||
class="ui:text-sm ui:font-medium ui:text-foreground ui:border-t ui:border-border/60 ui:pt-3 ui:mt-auto ui:flex ui:items-center ui:gap-1.5 ui:group-hover:text-primary ui:transition-colors"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if learnMoreLabel}
|
||||
<span>{learnMoreLabel}</span>
|
||||
{/if}
|
||||
<ArrowRightIcon class="ui:size-4" />
|
||||
</div>
|
||||
</Card.Root>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</EditableLandingSection>
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import MinimalNav from './minimal/nav.svelte';
|
||||
@@ -10,7 +11,7 @@
|
||||
import * as Card from '../../base/card';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import TagIcon from '@lucide/svelte/icons/tag';
|
||||
import { getPrimaryCourseTag } from './landing-page-utils';
|
||||
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
@@ -23,8 +24,8 @@
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
footerLinks,
|
||||
footerText
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function formatCurrency(cost?: number, currency = 'USD') {
|
||||
@@ -46,6 +47,7 @@
|
||||
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">Our Courses</h2>
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6">
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
||||
<BlurFade delay={0.1 * index} once={true}>
|
||||
<a
|
||||
@@ -76,6 +78,13 @@
|
||||
<span>{course.duration}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if courseTypeMeta}
|
||||
{@const TypeIcon = courseTypeMeta.icon}
|
||||
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
|
||||
<TypeIcon class={courseTypeMeta.iconClass} />
|
||||
<span>{courseTypeMeta.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if primaryTag}
|
||||
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
|
||||
<TagIcon class="ui:size-4" />
|
||||
@@ -116,9 +125,11 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="minimal" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="minimal" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="minimal" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="minimal" />
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="minimal" />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import { DotPattern } from '../../animation/dot-pattern';
|
||||
import { ShinnyText } from '../../animation/shinny-text';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
@@ -29,45 +30,47 @@
|
||||
`;
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="ui:relative ui:overflow-hidden ui:px-4 ui:pt-4 ui:pb-16 ui:md:pb-24"
|
||||
style={`background: ${heroGradientBackground};`}
|
||||
>
|
||||
<DotPattern class="ui:opacity-[0.08]" />
|
||||
<div class="ui:relative ui:max-w-[1200px] ui:mx-auto">
|
||||
{@render navigation()}
|
||||
</div>
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section
|
||||
class="ui:relative ui:overflow-hidden ui:px-4 ui:pt-4 ui:pb-16 ui:md:pb-24"
|
||||
style={`background: ${heroGradientBackground};`}
|
||||
>
|
||||
<DotPattern class="ui:opacity-[0.08]" />
|
||||
<div class="ui:relative ui:max-w-[1200px] ui:mx-auto">
|
||||
{@render navigation()}
|
||||
</div>
|
||||
|
||||
<div class="ui:relative ui:py-20 ui:text-center ui:max-w-4xl ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<ShinnyText
|
||||
shimmerWidth={120}
|
||||
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-foreground/72"
|
||||
>
|
||||
{hero.subheading}
|
||||
</ShinnyText>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:rounded-full"
|
||||
<div class="ui:relative ui:py-20 ui:text-center ui:max-w-4xl ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<ShinnyText
|
||||
shimmerWidth={120}
|
||||
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-foreground/72"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="minimal"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
{hero.subheading}
|
||||
</ShinnyText>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:rounded-full"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="minimal"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
@@ -23,28 +24,30 @@
|
||||
`;
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:px-6 ui:py-4 ui:rounded-full ui:border ui:border-border/60 ui:backdrop-blur-sm"
|
||||
style={`background: ${navigationGradientBackground};`}
|
||||
>
|
||||
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:items-center ui:gap-4">
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-8">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="ui:text-sm ui:text-foreground/70 ui:hover:text-foreground ui:transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} variant="outline" class="ui:rounded-full">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header
|
||||
class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:px-6 ui:py-4 ui:rounded-full ui:border ui:border-border/60 ui:backdrop-blur-sm"
|
||||
style={`background: ${navigationGradientBackground};`}
|
||||
>
|
||||
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
<div class="ui:flex ui:items-center ui:gap-4">
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-8">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="ui:text-sm ui:text-foreground/70 ui:hover:text-foreground ui:transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} variant="outline" class="ui:rounded-full">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import SaasNav from './saas/nav.svelte';
|
||||
import SaasHero from './saas/hero.svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import TagIcon from '@lucide/svelte/icons/tag';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
logoUrl,
|
||||
navItems,
|
||||
authAction,
|
||||
hero,
|
||||
courses,
|
||||
hasMoreCourses = false,
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function formatCurrency(cost?: number, currency = 'USD') {
|
||||
if (!cost) return 'Free';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="saas-root ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
||||
<div class="frame">
|
||||
<SaasHero {hero}>
|
||||
{#snippet navigation()}
|
||||
<SaasNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||
{/snippet}
|
||||
</SaasHero>
|
||||
|
||||
{#if courses.length > 0}
|
||||
<div class="plus-row"></div>
|
||||
|
||||
<section class="ui:py-20 ui:px-6">
|
||||
<div class="ui:max-w-[1180px] ui:mx-auto">
|
||||
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-12">
|
||||
<div>
|
||||
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">Catalog</p>
|
||||
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight">Featured programs.</h2>
|
||||
</div>
|
||||
{#if hasMoreCourses}
|
||||
<Button
|
||||
href={disableCourseLinks ? undefined : '/courses'}
|
||||
variant="outline"
|
||||
class="ui:rounded-full ui:self-start ui:md:self-auto"
|
||||
disabled={disableCourseLinks}
|
||||
>
|
||||
Browse catalog
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="course-grid ui:relative">
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
{@const primaryTag = getPrimaryCourseTag(course)}
|
||||
<BlurFade delay={0.06 * index} once={true} class="course-card-wrap">
|
||||
<a
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
class="course-card ui:flex ui:flex-col ui:h-full ui:no-underline ui:bg-background ui:transition-colors {disableCourseLinks
|
||||
? 'ui:cursor-default'
|
||||
: 'ui:hover:bg-muted/40'}"
|
||||
aria-disabled={disableCourseLinks}
|
||||
tabindex={disableCourseLinks ? -1 : undefined}
|
||||
>
|
||||
{#if course.logo}
|
||||
<div
|
||||
class="ui:h-40 ui:w-full ui:bg-cover ui:bg-center ui:relative"
|
||||
style={`background-image: url(${course.logo});`}
|
||||
>
|
||||
{#if courseTypeMeta}
|
||||
<span
|
||||
class="ui:absolute ui:top-3 ui:left-3 ui:bg-background/95 ui:text-foreground ui:text-[11px] ui:font-semibold ui:px-2.5 ui:py-1 ui:rounded-full"
|
||||
>
|
||||
{courseTypeMeta.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ui:p-5 ui:flex ui:flex-col ui:flex-1 ui:gap-2">
|
||||
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:leading-snug ui:m-0">
|
||||
{course.title}
|
||||
</h3>
|
||||
<p class="ui:text-sm ui:text-muted-foreground ui:line-clamp-2 ui:leading-relaxed ui:m-0 ui:flex-1">
|
||||
{course.description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:flex-wrap ui:text-xs ui:text-muted-foreground ui:mt-2"
|
||||
>
|
||||
{#if course.duration}
|
||||
<span class="ui:inline-flex ui:items-center ui:gap-1">
|
||||
<ClockIcon class="ui:size-3.5" />
|
||||
{course.duration}
|
||||
</span>
|
||||
{/if}
|
||||
{#if primaryTag}
|
||||
<span class="ui:inline-flex ui:items-center ui:gap-1">
|
||||
<TagIcon class="ui:size-3.5" />
|
||||
{#if primaryTag.color}
|
||||
<span
|
||||
class="ui:inline-block ui:size-2 ui:rounded-full"
|
||||
style={`background-color: ${primaryTag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
{primaryTag.name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-2 ui:border-t ui:border-border/60"
|
||||
>
|
||||
<span class="ui:text-sm ui:font-bold">
|
||||
{course.price || formatCurrency(course.cost, course.currency)}
|
||||
</span>
|
||||
<span
|
||||
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-xs ui:font-semibold ui:text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Enroll
|
||||
<ArrowRightIcon class="ui:size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if links && links.cards.length > 0}
|
||||
<div class="plus-row"></div>
|
||||
<OrgLandingPageLinks {links} variant="saas" />
|
||||
{/if}
|
||||
|
||||
{#if embed}
|
||||
<div class="plus-row"></div>
|
||||
<OrgLandingPageEmbed {embed} variant="saas" />
|
||||
{/if}
|
||||
|
||||
{#if callout}
|
||||
<div class="plus-row"></div>
|
||||
<OrgLandingPageCallout {callout} variant="saas" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="saas" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ============ FRAME (vertical lines + dotted bg) ============ */
|
||||
.saas-root :global(.frame) {
|
||||
position: relative;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
background-image: radial-gradient(
|
||||
circle at center,
|
||||
color-mix(in oklab, var(--foreground) 5%, transparent) 1px,
|
||||
transparent 1.5px
|
||||
);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.saas-root :global(.frame::before),
|
||||
.saas-root :global(.frame::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.saas-root :global(.frame::before) {
|
||||
left: 0;
|
||||
}
|
||||
.saas-root :global(.frame::after) {
|
||||
right: 0;
|
||||
}
|
||||
.saas-root :global(.frame > *) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ============ PLUS-ROW (dashed divider + + markers) ============ */
|
||||
.saas-root :global(.plus-row) {
|
||||
position: relative;
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--border);
|
||||
margin: 0;
|
||||
}
|
||||
.saas-root :global(.plus-row::before),
|
||||
.saas-root :global(.plus-row::after) {
|
||||
content: '+';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1;
|
||||
background: var(--background);
|
||||
padding: 1px 3px;
|
||||
z-index: 2;
|
||||
}
|
||||
.saas-root :global(.plus-row::before) {
|
||||
left: -8px;
|
||||
}
|
||||
.saas-root :global(.plus-row::after) {
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
/* ============ LINE-SEPARATED COURSE GRID ============ */
|
||||
.saas-root :global(.course-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.saas-root :global(.course-grid > .course-card-wrap) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
/* 1 course → centered card (drops line frame) */
|
||||
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child)) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
gap: 0;
|
||||
}
|
||||
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) > .course-card-wrap) {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
}
|
||||
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) .course-card) {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 2 courses → halves */
|
||||
.saas-root :global(.course-grid:has(> .course-card-wrap:nth-child(2):last-child) > .course-card-wrap) {
|
||||
grid-column: span 3;
|
||||
}
|
||||
/* 4 courses → 2 × 2 halves */
|
||||
.saas-root :global(.course-grid:has(> .course-card-wrap:nth-child(4):last-child) > .course-card-wrap) {
|
||||
grid-column: span 3;
|
||||
}
|
||||
/* 6+ courses → all thirds */
|
||||
.saas-root :global(.course-grid:has(> .course-card-wrap:nth-child(6)) > .course-card-wrap) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.saas-root :global(.course-card-wrap) {
|
||||
display: flex;
|
||||
}
|
||||
.saas-root :global(.course-card-wrap > a) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from '../types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||
import { Button } from '../../../base/button';
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
navigation: Snippet;
|
||||
}
|
||||
|
||||
let { hero, navigation }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section class="ui:relative ui:px-6 ui:pt-4 ui:pb-20 ui:md:pb-24">
|
||||
<div class="ui:relative ui:max-w-[1180px] ui:mx-auto ui:sticky ui:top-4 ui:z-50">
|
||||
{@render navigation()}
|
||||
</div>
|
||||
|
||||
<div class="ui:relative ui:max-w-[1180px] ui:mx-auto ui:pt-16 ui:md:pt-20 ui:text-center">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h1
|
||||
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-bold ui:tracking-tight ui:leading-[1.05] ui:max-w-3xl ui:mx-auto ui:mb-5"
|
||||
>
|
||||
{hero.heading}
|
||||
</h1>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.15} once={true}>
|
||||
<p
|
||||
class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mx-auto ui:mb-8 ui:leading-relaxed"
|
||||
>
|
||||
{hero.subheading}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-2.5">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
class="ui:rounded-full ui:px-6"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="saas" />
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
{#if hero.image}
|
||||
<BlurFade delay={0.45} once={true}>
|
||||
<div class="ui:mt-16 ui:max-w-4xl ui:mx-auto">
|
||||
<img
|
||||
src={hero.image}
|
||||
alt=""
|
||||
class="ui:w-full ui:h-auto ui:rounded-2xl ui:border ui:border-border ui:shadow-[0_20px_60px_-30px_rgba(14,14,16,0.25)]"
|
||||
/>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
navItems: NavItem[];
|
||||
authAction?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header
|
||||
class="ui:flex ui:items-center ui:justify-between ui:gap-4 ui:py-2 ui:pl-5 ui:pr-2 ui:rounded-full ui:border ui:border-border ui:bg-background/85 ui:backdrop-blur-md ui:shadow-[0_4px_24px_-8px_rgba(14,14,16,0.08)]"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:no-underline"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
|
||||
{#if navItems.length > 0}
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-1">
|
||||
{#each navItems as item (item.href + item.label)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:px-3.5 ui:py-1.5 ui:text-sm ui:font-medium ui:text-foreground/80 ui:hover:text-foreground ui:hover:bg-muted/60 ui:rounded-full ui:transition-colors ui:no-underline"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-1.5">
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} variant="default" size="sm" class="ui:rounded-full ui:px-4">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
@@ -29,6 +29,30 @@
|
||||
classic: {
|
||||
className: 'ui:px-8 ui:font-semibold ui:bg-background ui:text-foreground ui:border-border ui:transition-colors',
|
||||
variant: 'outline'
|
||||
},
|
||||
saas: {
|
||||
className: 'ui:rounded-full ui:px-6 ui:border-border ui:text-foreground ui:hover:bg-accent',
|
||||
variant: 'outline'
|
||||
},
|
||||
tech: {
|
||||
className:
|
||||
'ui:rounded-none ui:px-6 ui:font-semibold ui:border-black ui:text-white ui:bg-black ui:hover:bg-black/80 ui:transition-colors',
|
||||
variant: 'outline'
|
||||
},
|
||||
studio: {
|
||||
className:
|
||||
'ui:rounded-md ui:px-5 ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted ui:transition-colors',
|
||||
variant: 'outline'
|
||||
},
|
||||
corporate: {
|
||||
className:
|
||||
'ui:rounded-none ui:px-6 ui:font-medium ui:border-foreground ui:text-foreground ui:hover:bg-foreground ui:hover:text-background ui:transition-colors',
|
||||
variant: 'outline'
|
||||
},
|
||||
terminal: {
|
||||
className:
|
||||
'ui:rounded-full ui:px-5 ui:font-medium ui:bg-white/5 ui:border-[#262a35] ui:text-[#e9eaed] ui:hover:bg-white/10 ui:transition-colors',
|
||||
variant: 'outline'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import StudioNav from './studio/nav.svelte';
|
||||
import StudioHero from './studio/hero.svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
logoUrl,
|
||||
navItems,
|
||||
authAction,
|
||||
hero,
|
||||
courses,
|
||||
hasMoreCourses = false,
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function formatCurrency(cost?: number, currency = 'USD') {
|
||||
if (!cost) return 'Free';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
||||
<main>
|
||||
<StudioHero {hero} {courses}>
|
||||
{#snippet navigation()}
|
||||
<StudioNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||
{/snippet}
|
||||
</StudioHero>
|
||||
|
||||
{#if courses.length > 0}
|
||||
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
|
||||
<div class="ui:max-w-[1080px] ui:mx-auto">
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:items-end ui:mb-12">
|
||||
<div>
|
||||
<p class="ui:text-sm ui:text-muted-foreground ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
|
||||
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
|
||||
Catalog
|
||||
</p>
|
||||
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">
|
||||
Programs your team can actually finish.
|
||||
</h2>
|
||||
</div>
|
||||
{#if hasMoreCourses}
|
||||
<div class="ui:flex ui:justify-start ui:md:justify-end">
|
||||
<Button
|
||||
href={disableCourseLinks ? undefined : '/courses'}
|
||||
variant="outline"
|
||||
class="ui:rounded-md"
|
||||
disabled={disableCourseLinks}
|
||||
>
|
||||
Browse all →
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3">
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
<BlurFade delay={0.06 * index} once={true}>
|
||||
<a
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
class="ui:group ui:flex ui:flex-col ui:h-full ui:bg-card ui:border ui:border-border ui:rounded-xl ui:overflow-hidden ui:transition-colors ui:no-underline {disableCourseLinks
|
||||
? 'ui:cursor-default'
|
||||
: 'ui:hover:border-foreground/30'}"
|
||||
aria-disabled={disableCourseLinks}
|
||||
tabindex={disableCourseLinks ? -1 : undefined}
|
||||
>
|
||||
<div
|
||||
class="ui:relative ui:h-[200px] ui:bg-muted/50 ui:border-b ui:border-border ui:p-5 ui:overflow-hidden"
|
||||
>
|
||||
{#if course.logo}
|
||||
<img
|
||||
src={course.logo}
|
||||
alt=""
|
||||
class="ui:absolute ui:inset-0 ui:w-full ui:h-full ui:object-cover ui:opacity-60"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class="ui:relative ui:flex ui:items-start ui:justify-between"
|
||||
style="background: linear-gradient(180deg, transparent 60%, var(--card) 100%);"
|
||||
>
|
||||
{#if courseTypeMeta}
|
||||
<span
|
||||
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:px-2 ui:py-0.5 ui:bg-background/95 ui:border ui:border-border ui:rounded-md ui:text-[11px] ui:font-medium ui:text-foreground"
|
||||
>
|
||||
{courseTypeMeta.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui:p-5 ui:flex ui:items-end ui:justify-between ui:gap-3 ui:flex-1">
|
||||
<div class="ui:flex-1 ui:min-w-0">
|
||||
<p class="ui:text-[13px] ui:text-muted-foreground ui:mb-1.5 ui:m-0">
|
||||
{course.price || formatCurrency(course.cost, course.currency)}
|
||||
</p>
|
||||
<h3 class="ui:text-base ui:font-medium ui:tracking-tight ui:leading-snug ui:m-0">
|
||||
{course.title}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
class="ui:flex-shrink-0 ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-border ui:text-muted-foreground ui:group-hover:bg-foreground ui:group-hover:text-background ui:group-hover:border-foreground ui:transition-colors"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ArrowRightIcon class="ui:size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="studio" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="studio" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="studio" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="studio" />
|
||||
</div>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from '../types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||
import { Button } from '../../../base/button';
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
courses: OrgLandingPageProps['courses'];
|
||||
navigation: Snippet;
|
||||
}
|
||||
|
||||
let { hero, courses, navigation }: Props = $props();
|
||||
|
||||
const programsCount = $derived(courses.length);
|
||||
|
||||
const statusLabel = $derived(
|
||||
programsCount > 0
|
||||
? `Now in session · ${programsCount} ${programsCount === 1 ? 'program' : 'programs'} running`
|
||||
: 'Studio opening soon'
|
||||
);
|
||||
|
||||
const marqueeItems = $derived(courses.slice(0, 10));
|
||||
const showMarquee = $derived(marqueeItems.length > 0);
|
||||
</script>
|
||||
|
||||
{@render navigation()}
|
||||
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-20 ui:pb-20 ui:md:pt-24 ui:md:pb-24">
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:-top-32 ui:-left-24 ui:h-[520px] ui:w-[520px] ui:rounded-full ui:opacity-60 ui:blur-3xl"
|
||||
style="background: radial-gradient(circle, color-mix(in oklab, var(--primary) 35%, transparent) 0%, transparent 70%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:-top-20 ui:right-[-10%] ui:h-[420px] ui:w-[420px] ui:rounded-full ui:opacity-40 ui:blur-3xl"
|
||||
style="background: radial-gradient(circle, color-mix(in oklab, var(--primary) 18%, transparent) 0%, transparent 70%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="ui:relative ui:max-w-[1080px] ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<span
|
||||
class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-7 ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-border ui:bg-card/70 ui:backdrop-blur-sm ui:text-[12px] ui:font-medium ui:text-foreground"
|
||||
>
|
||||
<span class="studio-status-dot ui:size-1.5 ui:rounded-full" aria-hidden="true"></span>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.08} once={true}>
|
||||
<h1
|
||||
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[64px] ui:font-semibold ui:tracking-tight ui:leading-[1.02] ui:max-w-[18ch] ui:mb-5 ui:text-foreground ui:text-balance"
|
||||
>
|
||||
{hero.heading}
|
||||
</h1>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.18} once={true}>
|
||||
<p class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
||||
{hero.subheading}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.28} once={true}>
|
||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
class="ui:rounded-md ui:px-5"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="studio"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
{#if hero.image}
|
||||
<BlurFade delay={0.4} once={true}>
|
||||
<div
|
||||
class="studio-polaroid ui:mt-16 ui:relative ui:rounded-xl ui:border ui:border-border ui:bg-card ui:p-2 ui:shadow-[0_40px_90px_-30px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<img src={hero.image} alt="" class="ui:w-full ui:h-auto ui:rounded-lg ui:border ui:border-border" />
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
|
||||
{#if showMarquee}
|
||||
<BlurFade delay={hero.image ? 0.5 : 0.4} once={true}>
|
||||
<div class="studio-marquee-mask ui:mt-16 ui:py-4 ui:border-y ui:border-border" aria-hidden="true">
|
||||
<div class="studio-marquee-track ui:flex ui:gap-12 ui:whitespace-nowrap ui:w-max">
|
||||
{#each [...marqueeItems, ...marqueeItems] as course, index (`${course.id}-${index}`)}
|
||||
<span class="ui:inline-flex ui:items-center ui:gap-3">
|
||||
<span class="ui:size-1 ui:rounded-full ui:bg-primary/70"></span>
|
||||
<span class="ui:text-sm ui:font-medium ui:text-muted-foreground">
|
||||
{course.title}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
|
||||
<style>
|
||||
.studio-status-dot {
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 60%, transparent);
|
||||
animation: studio-pulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes studio-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 50%, transparent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px color-mix(in oklab, var(--primary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.studio-polaroid {
|
||||
transform: rotate(-0.8deg);
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
.studio-polaroid:hover {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.studio-marquee-mask {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent 0, black 8%, black 92%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, black 8%, black 92%, transparent 100%);
|
||||
}
|
||||
|
||||
.studio-marquee-track {
|
||||
animation: studio-marquee 40s linear infinite;
|
||||
}
|
||||
|
||||
.studio-marquee-mask:hover .studio-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes studio-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.studio-status-dot,
|
||||
.studio-marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
.studio-polaroid {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
navItems: NavItem[];
|
||||
authAction?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-background/75 ui:backdrop-blur-md ui:border-b ui:border-border">
|
||||
<div class="ui:max-w-[1080px] ui:mx-auto ui:px-6">
|
||||
<div class="ui:grid ui:grid-cols-[1fr_auto_1fr] ui:items-center ui:h-14">
|
||||
<a
|
||||
href="/"
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[14px] ui:tracking-tight ui:text-foreground ui:no-underline"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-5 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
|
||||
{#if navItems.length > 0}
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-0.5 ui:justify-self-center">
|
||||
{#each navItems as item (item.href + item.label)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:px-3 ui:py-1.5 ui:text-[13px] ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:rounded-md ui:transition-colors ui:no-underline"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
|
||||
<div class="ui:justify-self-end ui:flex ui:items-center ui:gap-1">
|
||||
{#if authAction}
|
||||
<Button href={authAction.href} size="sm" class="ui:rounded-md ui:px-3.5">
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import TechNav from './tech/nav.svelte';
|
||||
import TechHero from './tech/hero.svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
logoUrl,
|
||||
navItems,
|
||||
authAction,
|
||||
hero,
|
||||
courses,
|
||||
hasMoreCourses = false,
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function formatCurrency(cost?: number, currency = 'USD') {
|
||||
if (!cost) return 'Free';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
|
||||
<main>
|
||||
<TechHero {hero}>
|
||||
{#snippet navigation()}
|
||||
<TechNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||
{/snippet}
|
||||
</TechHero>
|
||||
|
||||
{#if courses.length > 0}
|
||||
<section class="ui:py-24 ui:px-6">
|
||||
<div class="ui:max-w-[1280px] ui:mx-auto">
|
||||
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-14">
|
||||
<div>
|
||||
<p class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">// catalog</p>
|
||||
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">Latest courses</h2>
|
||||
</div>
|
||||
{#if hasMoreCourses}
|
||||
<Button
|
||||
href={disableCourseLinks ? undefined : '/courses'}
|
||||
variant="outline"
|
||||
class="ui:rounded-none ui:font-mono ui:text-sm"
|
||||
disabled={disableCourseLinks}
|
||||
>
|
||||
view all →
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-border"
|
||||
>
|
||||
{#each courses as course, index (course.id)}
|
||||
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
|
||||
<BlurFade delay={0.06 * index} once={true}>
|
||||
<a
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
class="ui:flex ui:flex-col ui:h-full ui:p-7 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:no-underline {disableCourseLinks
|
||||
? 'ui:cursor-default'
|
||||
: 'ui:hover:bg-muted/40'}"
|
||||
aria-disabled={disableCourseLinks}
|
||||
tabindex={disableCourseLinks ? -1 : undefined}
|
||||
>
|
||||
{#if courseTypeMeta}
|
||||
<span
|
||||
class="ui:inline-block ui:self-start ui:px-2.5 ui:py-1 ui:bg-primary ui:text-primary-foreground ui:font-mono ui:text-[10px] ui:font-semibold ui:tracking-wider ui:uppercase ui:mb-4"
|
||||
>
|
||||
{courseTypeMeta.label}
|
||||
</span>
|
||||
{/if}
|
||||
<h3 class="ui:text-xl ui:font-bold ui:tracking-tight ui:leading-tight ui:mb-3 ui:m-0">
|
||||
{course.title}
|
||||
</h3>
|
||||
<p
|
||||
class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0 ui:mb-5"
|
||||
>
|
||||
{course.description}
|
||||
</p>
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:border-t ui:border-dashed ui:border-border"
|
||||
>
|
||||
<span class="ui:font-mono ui:text-sm ui:font-bold">
|
||||
{course.price || formatCurrency(course.cost, course.currency)}
|
||||
</span>
|
||||
<span
|
||||
class="ui:inline-flex ui:items-center ui:gap-1 ui:font-mono ui:text-xs ui:text-primary ui:font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
enroll
|
||||
<ArrowRightIcon class="ui:size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
{#if course.duration}
|
||||
<p
|
||||
class="ui:font-mono ui:text-[11px] ui:text-muted-foreground ui:mt-3 ui:inline-flex ui:items-center ui:gap-1.5"
|
||||
>
|
||||
<ClockIcon class="ui:size-3" />
|
||||
{course.duration}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="tech" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="tech" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="tech" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="tech" />
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps } from '../types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||
import { Button } from '../../../base/button';
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
navigation: Snippet;
|
||||
}
|
||||
|
||||
let { hero, navigation }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section class="ui:relative ui:bg-primary ui:text-primary-foreground ui:overflow-hidden ui:px-6 ui:pt-2 ui:pb-20">
|
||||
<!-- subtle grid overlay -->
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:inset-0 ui:opacity-[0.08]"
|
||||
style="background-image: linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px); background-size: 56px 56px;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="ui:relative ui:max-w-[1280px] ui:mx-auto">
|
||||
{@render navigation()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ui:relative ui:max-w-[1280px] ui:mx-auto ui:py-16 ui:lg:py-24 ui:grid ui:lg:grid-cols-2 ui:gap-12 ui:items-center"
|
||||
>
|
||||
<div>
|
||||
<BlurFade delay={0} once={true}>
|
||||
<p
|
||||
class="ui:inline-block ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-primary-foreground/80 ui:bg-primary-foreground/10 ui:px-3 ui:py-1.5 ui:mb-6"
|
||||
>
|
||||
// engineering academy
|
||||
</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-extrabold ui:tracking-tight ui:leading-[0.95] ui:mb-6">
|
||||
{hero.heading}
|
||||
</h1>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.24} once={true}>
|
||||
<p class="ui:text-lg ui:lg:text-xl ui:text-primary-foreground/80 ui:max-w-xl ui:mb-8 ui:leading-relaxed">
|
||||
{hero.subheading}
|
||||
</p>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.36} once={true}>
|
||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="ui:rounded-none ui:px-7 ui:font-semibold ui:bg-primary-foreground ui:text-primary ui:hover:bg-primary-foreground/90"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
{#if hero.secondaryAction}
|
||||
<SecondaryActionButton
|
||||
href={hero.secondaryAction.href}
|
||||
label={hero.secondaryAction.label}
|
||||
variant="tech"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</div>
|
||||
|
||||
{#if hero.image}
|
||||
<BlurFade delay={0.3} once={true} class="ui:hidden ui:lg:block">
|
||||
<div class="ui:relative">
|
||||
<div class="ui:absolute ui:inset-0 ui:bg-primary-foreground/10 ui:translate-x-3 ui:translate-y-3"></div>
|
||||
<img
|
||||
src={hero.image}
|
||||
alt=""
|
||||
class="ui:relative ui:w-full ui:h-[460px] ui:object-cover ui:border ui:border-primary-foreground/20"
|
||||
/>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
navItems: NavItem[];
|
||||
authAction?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:py-4 ui:text-primary-foreground">
|
||||
<a
|
||||
href="/"
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:font-bold ui:text-base ui:tracking-tight ui:text-primary-foreground ui:no-underline"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
|
||||
{/if}
|
||||
{orgName}
|
||||
</a>
|
||||
|
||||
{#if navItems.length > 0}
|
||||
<nav class="ui:hidden ui:md:flex ui:gap-1.5 ui:font-mono ui:text-[13px]">
|
||||
{#each navItems as item (item.href + item.label)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-transparent ui:text-primary-foreground/85 ui:hover:text-primary-foreground ui:hover:border-primary-foreground/30 ui:hover:bg-primary-foreground/10 ui:transition-colors ui:no-underline"
|
||||
>
|
||||
( {item.label.toLowerCase()} )
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
{#if authAction}
|
||||
<Button
|
||||
href={authAction.href}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="ui:rounded-none ui:font-semibold ui:border-primary-foreground ui:text-primary-foreground ui:bg-transparent ui:hover:bg-primary-foreground/10 ui:hover:text-primary-foreground"
|
||||
>
|
||||
{authAction.label} →
|
||||
</Button>
|
||||
{/if}
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps, CourseItem } from './types';
|
||||
import OrgLandingPageEmbed from './embed.svelte';
|
||||
import OrgLandingPageLinks from './links.svelte';
|
||||
import OrgLandingPageCallout from './callout.svelte';
|
||||
import OrgLandingPageFooter from './landing-page-footer.svelte';
|
||||
import TerminalNav from './terminal/nav.svelte';
|
||||
import TerminalHero from './terminal/hero.svelte';
|
||||
import { Button } from '../../base/button';
|
||||
import { BlurFade } from '../animation/blurfade';
|
||||
import { getCourseTypeLandingMeta } from './landing-page-utils';
|
||||
|
||||
let {
|
||||
orgName,
|
||||
logoUrl,
|
||||
navItems,
|
||||
authAction,
|
||||
hero,
|
||||
courses,
|
||||
hasMoreCourses = false,
|
||||
disableCourseLinks = false,
|
||||
embed,
|
||||
callout,
|
||||
links,
|
||||
footer
|
||||
}: OrgLandingPageProps = $props();
|
||||
|
||||
function priceLabel(course: CourseItem): string {
|
||||
if (course.price) return course.price;
|
||||
if (!course.cost) return 'Free';
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: course.currency ?? 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(course.cost);
|
||||
}
|
||||
|
||||
type TabKey = 'all' | string;
|
||||
|
||||
const courseTypeKeys = $derived(
|
||||
Array.from(new Set(courses.map((course) => course.type).filter((t): t is string => Boolean(t))))
|
||||
);
|
||||
|
||||
const tabs = $derived<{ key: TabKey; label: string }[]>([
|
||||
{ key: 'all', label: 'All' },
|
||||
...courseTypeKeys.map((type) => {
|
||||
const meta = getCourseTypeLandingMeta({ id: '', title: '', description: '', type } as CourseItem);
|
||||
|
||||
return { key: type, label: meta?.label ?? type };
|
||||
})
|
||||
]);
|
||||
|
||||
let activeTab = $state<TabKey>('all');
|
||||
|
||||
const visibleCourses = $derived(
|
||||
activeTab === 'all' ? courses : courses.filter((course) => course.type === activeTab)
|
||||
);
|
||||
|
||||
function slugify(course: CourseItem): string {
|
||||
if (course.slug) return course.slug;
|
||||
|
||||
return course.title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:min-h-screen ui:font-sans ui:w-full"
|
||||
style="
|
||||
background: #06070a;
|
||||
color: #e9eaed;
|
||||
--terminal-bg: #06070a;
|
||||
--terminal-bg-soft: #0c0e13;
|
||||
--terminal-bg-card: #0f1218;
|
||||
--terminal-bg-elevated: #14171f;
|
||||
--terminal-line: #1c1f28;
|
||||
--terminal-line-soft: #161922;
|
||||
--terminal-line-strong: #262a35;
|
||||
--terminal-ink: #e9eaed;
|
||||
--terminal-ink-dim: #9da1ab;
|
||||
--terminal-ink-muted: #61656f;
|
||||
--terminal-ink-faint: #3d4049;
|
||||
"
|
||||
>
|
||||
<main>
|
||||
<TerminalHero {orgName} {hero} {courses}>
|
||||
{#snippet navigation()}
|
||||
<TerminalNav {orgName} {logoUrl} {navItems} {authAction} />
|
||||
{/snippet}
|
||||
</TerminalHero>
|
||||
|
||||
{#if courses.length > 0}
|
||||
<section
|
||||
class="ui:py-24 ui:px-6"
|
||||
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg);"
|
||||
>
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<p
|
||||
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
|
||||
style="color: var(--primary);"
|
||||
>
|
||||
<span
|
||||
class="ui:size-1.5 ui:rounded-full"
|
||||
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
|
||||
></span>
|
||||
Catalog
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.08} once={true}>
|
||||
<h2
|
||||
class="ui:text-4xl ui:lg:text-[40px] ui:font-semibold ui:tracking-tight ui:leading-[1.08] ui:m-0 ui:mb-3 ui:max-w-[700px]"
|
||||
style="color: var(--terminal-ink);"
|
||||
>
|
||||
Programs your team can actually finish.
|
||||
</h2>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.14} once={true}>
|
||||
<p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8" style="color: var(--terminal-ink-dim);">
|
||||
Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
{#if tabs.length > 1}
|
||||
<BlurFade delay={0.2} once={true}>
|
||||
<div
|
||||
class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8"
|
||||
style="border: 1px solid var(--terminal-line-strong); background: var(--terminal-bg-card);"
|
||||
role="tablist"
|
||||
>
|
||||
{#each tabs as tab (tab.key)}
|
||||
{@const isActive = activeTab === tab.key}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onclick={() => (activeTab = tab.key)}
|
||||
class="ui:px-3.5 ui:py-1.5 ui:text-[13px] ui:font-medium ui:rounded-full ui:transition-colors"
|
||||
style={isActive
|
||||
? 'background: var(--terminal-ink); color: var(--terminal-bg);'
|
||||
: 'background: transparent; color: var(--terminal-ink-dim);'}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
|
||||
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]">
|
||||
{#each visibleCourses as course, index (course.id)}
|
||||
<BlurFade delay={0.04 * index} once={true}>
|
||||
<a
|
||||
href={disableCourseLinks
|
||||
? undefined
|
||||
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
|
||||
aria-disabled={disableCourseLinks}
|
||||
tabindex={disableCourseLinks ? -1 : undefined}
|
||||
class="ui:relative ui:flex ui:flex-col ui:gap-2.5 ui:p-6 ui:rounded-xl ui:no-underline ui:transition-colors ui:h-full"
|
||||
style="
|
||||
background: linear-gradient(180deg, var(--terminal-bg-card) 0%, var(--terminal-bg-soft) 100%);
|
||||
border: 1px solid var(--terminal-line);
|
||||
color: var(--terminal-ink);
|
||||
min-height: 180px;
|
||||
cursor: {disableCourseLinks ? 'default' : 'pointer'};
|
||||
"
|
||||
onmouseenter={(e) =>
|
||||
!disableCourseLinks && (e.currentTarget.style.borderColor = 'var(--terminal-line-strong)')}
|
||||
onmouseleave={(e) => (e.currentTarget.style.borderColor = 'var(--terminal-line)')}
|
||||
>
|
||||
<span class="ui:font-mono ui:text-[11px] ui:tracking-[0.04em]" style="color: var(--primary);">
|
||||
$ {slugify(course)}
|
||||
</span>
|
||||
<h3
|
||||
class="ui:text-[17px] ui:font-semibold ui:leading-snug ui:m-0"
|
||||
style="color: var(--terminal-ink); letter-spacing: -0.01em;"
|
||||
>
|
||||
{course.title}
|
||||
</h3>
|
||||
<p
|
||||
class="ui:text-[13.5px] ui:leading-[1.5] ui:m-0 ui:flex-1 ui:line-clamp-3"
|
||||
style="color: var(--terminal-ink-dim);"
|
||||
>
|
||||
{course.description}
|
||||
</p>
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:justify-between ui:pt-3.5 ui:font-mono ui:text-[12px]"
|
||||
style="border-top: 1px dashed var(--terminal-line);"
|
||||
>
|
||||
<span style="color: var(--terminal-ink);">{priceLabel(course)}</span>
|
||||
<span aria-hidden="true" style="color: var(--terminal-ink-dim);">→</span>
|
||||
</div>
|
||||
</a>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasMoreCourses}
|
||||
<div class="ui:mt-7 ui:flex ui:justify-center">
|
||||
<Button
|
||||
href={disableCourseLinks ? undefined : '/courses'}
|
||||
variant="outline"
|
||||
disabled={disableCourseLinks}
|
||||
class="ui:rounded-full ui:px-5 ui:bg-transparent ui:border-[var(--terminal-line-strong)] ui:text-[var(--terminal-ink)] ui:hover:bg-white/5"
|
||||
>
|
||||
View all programs →
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<OrgLandingPageLinks {links} variant="terminal" />
|
||||
|
||||
<OrgLandingPageEmbed {embed} variant="terminal" />
|
||||
|
||||
<OrgLandingPageCallout {callout} variant="terminal" />
|
||||
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="terminal" />
|
||||
</div>
|
||||
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import type { OrgLandingPageProps, CourseItem } from '../types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Button } from '../../../base/button';
|
||||
import { BlurFade } from '../../animation/blurfade';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: OrgLandingPageProps['orgName'];
|
||||
hero: OrgLandingPageProps['hero'];
|
||||
courses: OrgLandingPageProps['courses'];
|
||||
navigation: Snippet;
|
||||
}
|
||||
|
||||
let { orgName, hero, courses, navigation }: Props = $props();
|
||||
|
||||
const programsCount = $derived(courses.length);
|
||||
const learnersCount = $derived(courses.reduce((sum, course) => sum + (course.totalStudents ?? 0), 0));
|
||||
const tracksCount = $derived(new Set(courses.map((course) => course.type).filter(Boolean)).size);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
const learnersFormatted = $derived(numberFormatter.format(learnersCount));
|
||||
|
||||
function priceLabel(course: CourseItem): string {
|
||||
if (course.price) return course.price;
|
||||
if (!course.cost) return 'Free';
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: course.currency ?? 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(course.cost);
|
||||
}
|
||||
|
||||
function durationLabel(course: CourseItem): string {
|
||||
if (course.duration) return course.duration;
|
||||
if (course.lessonCount && course.lessonCount > 0) {
|
||||
return `${course.lessonCount} ${course.lessonCount === 1 ? 'lesson' : 'lessons'}`;
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
|
||||
const terminalRows = $derived(
|
||||
courses.slice(0, 5).map((course, index) => ({
|
||||
n: String(index + 1).padStart(2, '0'),
|
||||
slug: course.slug ?? course.id,
|
||||
price: priceLabel(course),
|
||||
duration: durationLabel(course)
|
||||
}))
|
||||
);
|
||||
|
||||
const orgSlug = $derived(
|
||||
orgName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'org'
|
||||
);
|
||||
</script>
|
||||
|
||||
{@render navigation()}
|
||||
|
||||
<EditableLandingSection sectionKey="hero">
|
||||
<section
|
||||
class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-16 ui:pb-16 ui:md:pt-20 ui:md:pb-20"
|
||||
style="
|
||||
background: #06070a;
|
||||
--terminal-bg: #06070a;
|
||||
--terminal-bg-soft: #0c0e13;
|
||||
--terminal-bg-card: #0f1218;
|
||||
--terminal-bg-elevated: #14171f;
|
||||
--terminal-line: #1c1f28;
|
||||
--terminal-line-soft: #161922;
|
||||
--terminal-line-strong: #262a35;
|
||||
--terminal-ink: #e9eaed;
|
||||
--terminal-ink-dim: #9da1ab;
|
||||
--terminal-ink-muted: #61656f;
|
||||
--terminal-ink-faint: #3d4049;
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="ui:pointer-events-none ui:absolute ui:inset-x-0 ui:-top-32 ui:h-[680px] ui:opacity-90"
|
||||
style="background: radial-gradient(ellipse 60% 70% at 50% 30%, color-mix(in oklab, var(--primary) 22%, transparent) 0%, transparent 65%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="ui:relative ui:max-w-[1120px] ui:mx-auto ui:text-center">
|
||||
<BlurFade delay={0} once={true}>
|
||||
<h1
|
||||
class="ui:font-semibold ui:leading-[0.95] ui:mb-10 ui:max-w-[20ch] ui:mx-auto ui:text-balance"
|
||||
style="
|
||||
font-size: clamp(48px, 9vw, 128px);
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in oklab, var(--primary) 60%, #ffffff) 0%,
|
||||
color-mix(in oklab, var(--primary) 35%, transparent) 45%,
|
||||
rgba(8, 10, 14, 0.3) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 8px 60px color-mix(in oklab, var(--primary) 18%, transparent);
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
{hero.heading}
|
||||
</h1>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.12} once={true}>
|
||||
<div class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-8">
|
||||
{#if hero.secondaryAction}
|
||||
<Button
|
||||
href={hero.secondaryAction.href}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-white/5 ui:border-[var(--terminal-line-strong)] ui:text-[var(--terminal-ink)] ui:hover:bg-white/10"
|
||||
>
|
||||
{hero.secondaryAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
href={hero.primaryAction.href}
|
||||
disabled={hero.primaryAction.disabled ?? false}
|
||||
size="sm"
|
||||
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
|
||||
>
|
||||
{hero.primaryAction.label}
|
||||
</Button>
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<BlurFade delay={0.22} once={true}>
|
||||
<p class="ui:text-[15px] ui:mb-7 ui:max-w-xl ui:mx-auto" style="color: var(--terminal-ink-dim);">
|
||||
{hero.subheading}
|
||||
</p>
|
||||
</BlurFade>
|
||||
|
||||
{#if programsCount > 0}
|
||||
<BlurFade delay={0.3} once={true}>
|
||||
<span
|
||||
class="ui:inline-flex ui:items-center ui:gap-3 ui:px-4 ui:py-2 ui:rounded-full ui:font-mono ui:text-[13px]"
|
||||
style="
|
||||
border: 1px solid var(--terminal-line-strong);
|
||||
background: rgba(255,255,255,0.02);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--terminal-ink-dim);
|
||||
"
|
||||
>
|
||||
<span style="color: var(--primary);">></span>
|
||||
<span
|
||||
><span style="color: var(--terminal-ink); font-weight: 500;">{programsCount}</span>
|
||||
{programsCount === 1 ? 'program' : 'programs'} running</span
|
||||
>
|
||||
{#if learnersCount > 0}
|
||||
<span class="ui:inline-block ui:w-px ui:h-3" style="background: var(--terminal-line-strong);"></span>
|
||||
<span
|
||||
><span style="color: var(--terminal-ink); font-weight: 500;">{learnersFormatted}</span> learners</span
|
||||
>
|
||||
{/if}
|
||||
{#if tracksCount > 1}
|
||||
<span class="ui:inline-block ui:w-px ui:h-3" style="background: var(--terminal-line-strong);"></span>
|
||||
<span><span style="color: var(--terminal-ink); font-weight: 500;">{tracksCount}</span> tracks</span>
|
||||
{/if}
|
||||
</span>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
|
||||
{#if hero.image}
|
||||
<BlurFade delay={0.4} once={true}>
|
||||
<div
|
||||
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left"
|
||||
style="
|
||||
border: 1px solid var(--terminal-line-strong);
|
||||
background: var(--terminal-bg-card);
|
||||
box-shadow:
|
||||
0 30px 90px -30px rgba(0,0,0,0.7),
|
||||
0 0 0 1px color-mix(in oklab, var(--primary) 6%, transparent);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3"
|
||||
style="border-bottom: 1px solid var(--terminal-line); background: var(--terminal-bg-elevated);"
|
||||
>
|
||||
<span class="ui:flex ui:gap-1.5 ui:mr-2">
|
||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
|
||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #febc2e;"></span>
|
||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
|
||||
</span>
|
||||
<span
|
||||
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate"
|
||||
style="color: var(--terminal-ink-dim);"
|
||||
>
|
||||
{orgSlug}/preview
|
||||
</span>
|
||||
</div>
|
||||
<img src={hero.image} alt={orgName} class="ui:block ui:w-full ui:h-auto" />
|
||||
</div>
|
||||
</BlurFade>
|
||||
{:else if terminalRows.length > 0}
|
||||
<BlurFade delay={0.4} once={true}>
|
||||
<div
|
||||
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left"
|
||||
style="
|
||||
border: 1px solid var(--terminal-line-strong);
|
||||
background: var(--terminal-bg-card);
|
||||
box-shadow:
|
||||
0 30px 90px -30px rgba(0,0,0,0.7),
|
||||
0 0 0 1px color-mix(in oklab, var(--primary) 6%, transparent);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3"
|
||||
style="border-bottom: 1px solid var(--terminal-line); background: var(--terminal-bg-elevated);"
|
||||
>
|
||||
<span class="ui:flex ui:gap-1.5 ui:mr-2">
|
||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
|
||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #febc2e;"></span>
|
||||
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
|
||||
</span>
|
||||
<span
|
||||
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate"
|
||||
style="color: var(--terminal-ink-dim);"
|
||||
>
|
||||
{orgSlug}/main · catalog
|
||||
</span>
|
||||
<span class="ui:font-mono ui:text-[11px]" style="color: var(--terminal-ink-muted);">
|
||||
{programsCount}
|
||||
{programsCount === 1 ? 'program' : 'programs'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ui:p-5 ui:font-mono ui:text-[13px]" style="color: var(--terminal-ink); line-height: 1.7;">
|
||||
<div
|
||||
class="ui:flex ui:items-baseline ui:gap-2 ui:px-3 ui:py-2 ui:rounded-md ui:mb-3"
|
||||
style="background: rgba(255,255,255,0.02); border: 1px solid var(--terminal-line);"
|
||||
>
|
||||
<span style="color: var(--primary);">></span>
|
||||
<span>list programs --org {orgSlug}</span>
|
||||
</div>
|
||||
|
||||
<div class="ui:py-1 ui:text-[12px]" style="color: var(--terminal-ink-muted);">
|
||||
● Loaded {programsCount}
|
||||
{programsCount === 1 ? 'program' : 'programs'}
|
||||
</div>
|
||||
|
||||
<div class="ui:mt-2 ui:flex ui:flex-col">
|
||||
{#each terminalRows as row (row.slug)}
|
||||
<div class="ui:flex ui:items-baseline ui:gap-4 ui:py-1.5" style="color: var(--terminal-ink);">
|
||||
<span style="color: var(--terminal-ink-faint);">{row.n}</span>
|
||||
<span class="ui:truncate ui:flex-1" style="color: var(--primary);">{row.slug}</span>
|
||||
<span class="ui:text-[12px]" style="color: var(--terminal-ink-dim);">{row.duration}</span>
|
||||
<span class="ui:text-[12px] ui:w-20 ui:text-right">{row.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:items-baseline ui:gap-2 ui:mt-4">
|
||||
<span style="color: var(--primary);">></span>
|
||||
<span
|
||||
class="ui:inline-block ui:w-2 ui:h-[14px] ui:align-middle"
|
||||
style="background: var(--primary); animation: terminal-caret 1s steps(1) infinite;"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ui:flex ui:items-center ui:justify-between ui:px-4 ui:py-2.5 ui:font-mono ui:text-[11px]"
|
||||
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg-soft); color: var(--terminal-ink-muted);"
|
||||
>
|
||||
<div class="ui:flex ui:gap-4">
|
||||
<span>Enter enroll</span>
|
||||
<span>↑/↓ navigate</span>
|
||||
<span>^q quit</span>
|
||||
</div>
|
||||
<div style="color: var(--terminal-ink-dim);">{orgSlug}-cli</div>
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</EditableLandingSection>
|
||||
|
||||
<style>
|
||||
@keyframes terminal-caret {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[style*='terminal-caret'] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { NavItem } from '../types';
|
||||
import { Button } from '../../../base/button';
|
||||
import EditableLandingSection from '../editable-section.svelte';
|
||||
|
||||
interface Props {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
navItems: NavItem[];
|
||||
authAction?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { orgName, logoUrl, navItems, authAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EditableLandingSection sectionKey="navigation">
|
||||
<header class="ui:relative ui:z-50 ui:px-6" style="background: transparent;">
|
||||
<div class="ui:max-w-[1120px] ui:mx-auto">
|
||||
<div class="ui:flex ui:items-center ui:justify-between ui:h-16">
|
||||
<div class="ui:flex ui:items-center ui:gap-8">
|
||||
<a
|
||||
href="/"
|
||||
aria-label={orgName}
|
||||
class="ui:flex ui:items-center ui:gap-2 ui:no-underline ui:text-foreground"
|
||||
style="color: var(--terminal-ink, #e9eaed);"
|
||||
>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
|
||||
{:else}
|
||||
<svg viewBox="0 0 16 16" fill="none" class="ui:size-5">
|
||||
<path
|
||||
d="M2 2 L8 8 L2 14 M14 2 L8 8 L14 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if navItems.length > 0}
|
||||
<nav class="ui:hidden ui:md:flex ui:items-center ui:gap-6">
|
||||
{#each navItems as item (item.href + item.label)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:no-underline ui:transition-colors"
|
||||
style="color: var(--terminal-ink-dim, #9da1ab);"
|
||||
onmouseenter={(e) => (e.currentTarget.style.color = 'var(--terminal-ink, #e9eaed)')}
|
||||
onmouseleave={(e) => (e.currentTarget.style.color = 'var(--terminal-ink-dim, #9da1ab)')}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
<span class="ui:select-none" style="color: var(--terminal-ink-faint, #3d4049);" aria-hidden="true">/</span
|
||||
>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-3">
|
||||
{#if authAction}
|
||||
<Button
|
||||
href={authAction.href}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-transparent ui:border-[var(--terminal-line-strong,#262a35)] ui:text-[var(--terminal-ink,#e9eaed)] ui:hover:bg-white/5"
|
||||
>
|
||||
{authAction.label}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</EditableLandingSection>
|
||||
@@ -1,6 +1,14 @@
|
||||
export type NavItem = { label: string; href: string };
|
||||
|
||||
export type OrgLandingPageTheme = 'minimal' | 'bold' | 'classic';
|
||||
export type OrgLandingPageTheme =
|
||||
| 'minimal'
|
||||
| 'bold'
|
||||
| 'classic'
|
||||
| 'saas'
|
||||
| 'tech'
|
||||
| 'studio'
|
||||
| 'corporate'
|
||||
| 'terminal';
|
||||
|
||||
export type CourseItem = {
|
||||
id: string;
|
||||
@@ -33,7 +41,39 @@ export type CourseItem = {
|
||||
level?: string;
|
||||
};
|
||||
|
||||
export type FooterLink = { label: string; href: string };
|
||||
export type FooterSocialPlatform =
|
||||
| 'instagram'
|
||||
| 'x'
|
||||
| 'linkedin'
|
||||
| 'facebook'
|
||||
| 'youtube'
|
||||
| 'github'
|
||||
| 'tiktok'
|
||||
| 'website';
|
||||
|
||||
export type FooterSocial = { platform: FooterSocialPlatform; href: string };
|
||||
|
||||
export type FooterColumnLink = { id: string; label: string; href: string };
|
||||
|
||||
export type FooterColumn = {
|
||||
id: string;
|
||||
heading: string;
|
||||
links: FooterColumnLink[];
|
||||
cta?: { label: string; href: string };
|
||||
};
|
||||
|
||||
export type OrgLandingPageFooterConfig = {
|
||||
brand: {
|
||||
tagline?: string;
|
||||
copyright?: string;
|
||||
socials: FooterSocial[];
|
||||
};
|
||||
columns: FooterColumn[];
|
||||
bottom?: {
|
||||
text?: string;
|
||||
links: FooterColumnLink[];
|
||||
};
|
||||
};
|
||||
export type LandingPageEmbed = {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -53,6 +93,35 @@ export type LandingPageCallout = {
|
||||
};
|
||||
};
|
||||
|
||||
export type LandingPageLinkIcon =
|
||||
| 'help-circle'
|
||||
| 'life-buoy'
|
||||
| 'book-open'
|
||||
| 'video'
|
||||
| 'users'
|
||||
| 'message-circle'
|
||||
| 'newspaper'
|
||||
| 'rocket'
|
||||
| 'calendar'
|
||||
| 'mail';
|
||||
|
||||
export type LandingPageLinkCard = {
|
||||
icon: LandingPageLinkIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type LandingPageLinks = {
|
||||
heading: string;
|
||||
description?: string;
|
||||
/** Bold template card footer chip (e.g. Visit). */
|
||||
boldVisitLabel?: string;
|
||||
/** Classic template card footer (e.g. Learn more). */
|
||||
classicLearnMoreLabel?: string;
|
||||
cards: LandingPageLinkCard[];
|
||||
};
|
||||
|
||||
export interface OrgLandingPageProps {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
@@ -67,12 +136,13 @@ export interface OrgLandingPageProps {
|
||||
primaryAction: { label: string; href: string; disabled?: boolean };
|
||||
secondaryAction?: { label: string; href: string };
|
||||
image?: string;
|
||||
stats?: Array<{ label: string; value: string }>;
|
||||
};
|
||||
courses: CourseItem[];
|
||||
hasMoreCourses?: boolean;
|
||||
disableCourseLinks?: boolean;
|
||||
embed?: LandingPageEmbed;
|
||||
callout?: LandingPageCallout;
|
||||
footerLinks: FooterLink[];
|
||||
footerText: string;
|
||||
links?: LandingPageLinks;
|
||||
footer: OrgLandingPageFooterConfig;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:relative ui:flex ui:max-w-sm ui:flex-col ui:rounded-xl ui:p-8 ui:shadow-sm ui:transition-all ui:hover:shadow-md',
|
||||
isPopular ? 'animate-gradient-border' : 'ui:border',
|
||||
'ui:relative ui:flex ui:max-w-sm ui:flex-col ui:rounded-lg ui:border ui:border-border ui:bg-card ui:p-8',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { DotField } from '../animation/dot-field';
|
||||
import { Waves } from '../animation/waves';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseCalloutData } from './types';
|
||||
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData } from './types';
|
||||
|
||||
interface Props {
|
||||
callout: PublicCourseCalloutData | null;
|
||||
@@ -10,6 +12,7 @@
|
||||
/** Shown when callout is null and variant === 'full'. */
|
||||
lockedFallbackTitle?: string;
|
||||
lockedFallbackDescription?: string;
|
||||
animation?: PublicCourseCalloutAnimation;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -18,45 +21,78 @@
|
||||
variant = 'inline',
|
||||
lockedFallbackTitle = 'This lesson is locked',
|
||||
lockedFallbackDescription = 'The creator hasn’t shared a link for this lesson yet. Check back later.',
|
||||
animation = 'waves',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const cardBase = 'ui:rounded-xl ui:border ui:border-border ui:bg-card ui:text-card-foreground ui:transition-colors';
|
||||
const motionClass = 'ui:pointer-events-none ui:absolute ui:inset-0 ui:z-0 ui:h-full ui:w-full';
|
||||
|
||||
const cardBase = 'ui:rounded-xl ui:border ui:border-primary/50 ui:text-card-foreground ui:transition-colors';
|
||||
|
||||
const rootClass = $derived(
|
||||
variant === 'full'
|
||||
? cn(
|
||||
cardBase,
|
||||
'ui:my-10 ui:mx-auto ui:w-full ui:max-w-xl ui:p-8 ui:text-center ui:border-dashed ui:bg-muted/30',
|
||||
'ui:relative ui:overflow-hidden ui:my-10 ui:mx-auto ui:w-full ui:h-full ui:max-w-4xl ui:max-h-4xl ui:p-8 ui:flex ui:flex-col ui:items-center ui:justify-center ui:border-2 ui:border-primary/50 ui:border-dashed',
|
||||
className
|
||||
)
|
||||
: cn(cardBase, 'ui:mt-10 ui:p-6', className)
|
||||
: cn(cardBase, 'ui:relative ui:overflow-hidden ui:mt-10 ui:p-6', className)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet calloutMotionBackground()}
|
||||
{#if animation === 'waves'}
|
||||
<Waves class={motionClass} />
|
||||
{:else if animation === 'dotted'}
|
||||
<DotField
|
||||
dotRadius={1.5}
|
||||
dotSpacing={14}
|
||||
cursorRadius={420}
|
||||
bulgeOnly={true}
|
||||
bulgeStrength={56}
|
||||
glowRadius={140}
|
||||
sparkle={false}
|
||||
waveAmplitude={0}
|
||||
maxGlowOpacity={0.5}
|
||||
class={motionClass}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if callout}
|
||||
<aside class={rootClass}>
|
||||
<h3 class={cn('ui:font-semibold ui:text-foreground', variant === 'full' ? 'ui:text-xl' : 'ui:text-base')}>
|
||||
{callout.title}
|
||||
</h3>
|
||||
<p class={cn('ui:mt-2 ui:text-sm ui:text-muted-foreground', variant === 'full' && 'ui:mx-auto ui:max-w-md')}>
|
||||
{callout.description}
|
||||
</p>
|
||||
<a
|
||||
href={callout.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class={cn(
|
||||
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{callout.buttonLabel}
|
||||
<ArrowUpRightIcon class="ui:size-4" />
|
||||
</a>
|
||||
{@render calloutMotionBackground()}
|
||||
<div class="ui:relative ui:z-10 ui:flex ui:flex-col ui:items-center">
|
||||
<h3 class={cn('', variant === 'full' ? 'ui:text-3xl' : 'ui:text-xl')}>
|
||||
{callout.title}
|
||||
</h3>
|
||||
<p
|
||||
class={cn(
|
||||
'ui:mt-2 ui:text-md ui:text-muted-foreground ui:text-center',
|
||||
variant === 'full' && 'ui:mx-auto ui:max-w-md'
|
||||
)}
|
||||
>
|
||||
{callout.description}
|
||||
</p>
|
||||
<a
|
||||
href={callout.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class={cn(
|
||||
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{callout.buttonLabel}
|
||||
<ArrowUpRightIcon class="ui:size-4 custom" />
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
{:else if variant === 'full'}
|
||||
<aside class={rootClass}>
|
||||
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
|
||||
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
|
||||
{@render calloutMotionBackground()}
|
||||
<div class="ui:relative ui:z-10">
|
||||
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
|
||||
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
import { QuestionList } from '../exercise-question';
|
||||
import { cn } from '../../tools';
|
||||
import Callout from './callout.svelte';
|
||||
import type { PublicCourseCalloutData, PublicExerciseViewData } from './types';
|
||||
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicExerciseViewData } from './types';
|
||||
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
||||
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
|
||||
|
||||
interface Props {
|
||||
exercise: PublicExerciseViewData;
|
||||
callout?: PublicCourseCalloutData | null;
|
||||
calloutAnimation?: PublicCourseCalloutAnimation;
|
||||
labels?: ExerciseQuestionLabels;
|
||||
/** When set, completed attempts persist and hydrate from `localStorage`. */
|
||||
attemptsPersistenceKey?: string | null;
|
||||
@@ -36,6 +37,7 @@
|
||||
let {
|
||||
exercise,
|
||||
callout = null,
|
||||
calloutAnimation,
|
||||
labels,
|
||||
attemptsPersistenceKey = null,
|
||||
formatAttemptOption = ({ attemptNumber, correct, total }) => `Attempt ${attemptNumber} - ${correct}/${total}`,
|
||||
@@ -48,6 +50,10 @@
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
|
||||
calloutAnimation ?? callout?.animation ?? 'waves'
|
||||
);
|
||||
|
||||
let persistedAttempts = $state<PublicExerciseStoredAttempt[]>([]);
|
||||
/** `'live'` = unsaved/new round; `'0'`.. = index into persisted attempts. */
|
||||
let selectValue = $state<string>('live');
|
||||
@@ -335,7 +341,7 @@
|
||||
|
||||
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
{#if !exercise.isUnlocked}
|
||||
<Callout variant="full" {callout} />
|
||||
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
|
||||
{:else}
|
||||
{#if exercise.sectionTitle}
|
||||
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
|
||||
@@ -417,6 +423,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Callout variant="inline" {callout} />
|
||||
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
@@ -34,6 +34,23 @@
|
||||
|
||||
const prevHref = $derived(prev ? hrefFor?.(prev) : undefined);
|
||||
const nextHref = $derived(next ? hrefFor?.(next) : undefined);
|
||||
|
||||
function handleAnchorClick(event: MouseEvent, item: PublicCourseSidebarItem) {
|
||||
if (
|
||||
!onNavigate ||
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onNavigate(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet prevBody(item: PublicCourseSidebarItem, label: string)}
|
||||
@@ -67,7 +84,7 @@
|
||||
>
|
||||
{#if prev}
|
||||
{#if prevHref}
|
||||
<a href={prevHref} class={cardClass} onclick={() => onNavigate?.(prev)}>
|
||||
<a href={prevHref} class={cardClass} onclick={(event) => handleAnchorClick(event, prev)}>
|
||||
{@render prevBody(prev, prevLabel)}
|
||||
</a>
|
||||
{:else}
|
||||
@@ -81,7 +98,11 @@
|
||||
|
||||
{#if next}
|
||||
{#if nextHref}
|
||||
<a href={nextHref} class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
|
||||
<a
|
||||
href={nextHref}
|
||||
class={cn(cardClass, 'ui:justify-end ui:text-right')}
|
||||
onclick={(event) => handleAnchorClick(event, next)}
|
||||
>
|
||||
{@render nextBody(next, nextLabel)}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
export type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
|
||||
|
||||
export type {
|
||||
PublicCourseCalloutAnimation,
|
||||
PublicCourseCalloutData,
|
||||
PublicCourseOrgData,
|
||||
PublicCourseSidebarItem,
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../tools';
|
||||
import { SafeHtmlContent } from '../safe-html-content';
|
||||
import { MediaPlayer } from '../media-player';
|
||||
import Callout from './callout.svelte';
|
||||
import type { PublicCourseCalloutData, PublicLessonViewData } from './types';
|
||||
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicLessonViewData } from './types';
|
||||
|
||||
interface Props {
|
||||
lesson: PublicLessonViewData;
|
||||
/** Shown in the player captions menu; supplied by the host app (`$t(...)`) — see authenticated `lesson-video-player`. */
|
||||
videoCaptionsLabel: string;
|
||||
callout?: PublicCourseCalloutData | null;
|
||||
calloutAnimation?: PublicCourseCalloutAnimation;
|
||||
class?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { lesson, callout = null, class: className }: Props = $props();
|
||||
let { lesson, videoCaptionsLabel, callout = null, calloutAnimation, class: className, id }: Props = $props();
|
||||
|
||||
const isYoutube = $derived(lesson.video?.type === 'youtube');
|
||||
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
|
||||
calloutAnimation ?? callout?.animation ?? 'waves'
|
||||
);
|
||||
|
||||
function toEmbedUrl(link: string): string {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
if (url.hostname.includes('youtu.be')) {
|
||||
const videoId = url.pathname.replace(/^\//, '');
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
if (url.hostname.includes('youtube.com')) {
|
||||
const videoId = url.searchParams.get('v');
|
||||
if (videoId) return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
return link;
|
||||
} catch {
|
||||
return link;
|
||||
const captionTracks = $derived.by(() => {
|
||||
const transcript = lesson.video?.transcript;
|
||||
|
||||
if (!transcript?.vttUrl) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'captions' as const,
|
||||
src: transcript.vttUrl,
|
||||
srclang: transcript.language,
|
||||
label: videoCaptionsLabel,
|
||||
default: false
|
||||
}
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -39,26 +47,29 @@
|
||||
a public link. The `prose` class (no `ui:` prefix) hooks into the dashboard's
|
||||
app.css typography rules.
|
||||
-->
|
||||
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
<div {id} class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
|
||||
{#if !lesson.isUnlocked}
|
||||
<Callout variant="full" {callout} />
|
||||
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
|
||||
{:else}
|
||||
{#if lesson.video}
|
||||
<div class="ui:mb-6 ui:overflow-hidden ui:rounded-xl ui:bg-black">
|
||||
{#if isYoutube}
|
||||
<iframe
|
||||
class="ui:aspect-video ui:w-full"
|
||||
src={toEmbedUrl(lesson.video.link)}
|
||||
title={lesson.title}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{:else}
|
||||
<video class="ui:aspect-video ui:w-full" controls playsinline src={lesson.video.link}>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:bg-muted ui:rounded-lg">
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: lesson.video.type,
|
||||
url: lesson.video.link,
|
||||
metadata: {
|
||||
...(lesson.video.metadata ?? {}),
|
||||
title: lesson.video.metadata?.title ?? lesson.title
|
||||
},
|
||||
tracks: captionTracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -80,6 +91,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Callout variant="inline" {callout} />
|
||||
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { cn } from '../../tools';
|
||||
|
||||
interface Props {
|
||||
/** Course slug for attribution (passed as ?utm_source=...&utm_content=...). */
|
||||
/** Course slug for attribution (passed as ?utm_content=...). */
|
||||
courseSlug?: string | null;
|
||||
/** Org siteName (or org name) for attribution. */
|
||||
orgSlug?: string | null;
|
||||
@@ -10,6 +10,12 @@
|
||||
label?: string;
|
||||
/** Localized "ClassroomIO" brand label. */
|
||||
brand?: string;
|
||||
/** Logo-only footer (narrow sidebars). */
|
||||
compact?: boolean;
|
||||
/** Row alignment inside the attribution strip. */
|
||||
align?: 'center' | 'start';
|
||||
/** Override default utm_source (default mirrors public-course pages). */
|
||||
utmSource?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -18,13 +24,16 @@
|
||||
orgSlug = null,
|
||||
label = 'Powered by',
|
||||
brand = 'ClassroomIO',
|
||||
compact = false,
|
||||
align = 'center',
|
||||
utmSource = 'public-course',
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
const href = $derived.by(() => {
|
||||
const url = new URL('https://classroomio.com');
|
||||
|
||||
url.searchParams.set('utm_source', 'public-course');
|
||||
url.searchParams.set('utm_source', utmSource);
|
||||
url.searchParams.set('utm_medium', 'powered-by');
|
||||
|
||||
if (orgSlug) url.searchParams.set('utm_campaign', orgSlug);
|
||||
@@ -32,22 +41,39 @@
|
||||
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
const rowAlignClass = $derived(align === 'start' ? 'ui:justify-start' : 'ui:justify-center');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:px-3 ui:flex ui:items-center ui:justify-center ui:gap-1 ui:py-3 ui:text-xs ui:text-muted-foreground ui:w-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
|
||||
{label}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
|
||||
{#if compact}
|
||||
<div class={cn('ui:flex ui:items-center ui:gap-1 ui:w-full', rowAlignClass, className)}>
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:leading-none ui:opacity-70 ui:hover:opacity-100"
|
||||
aria-label={`${label} ${brand}`}
|
||||
>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="" />
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class={cn(
|
||||
'ui:px-3 ui:flex ui:items-center ui:gap-1 ui:text-xs ui:text-muted-foreground ui:w-full',
|
||||
rowAlignClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{brand}
|
||||
</a>
|
||||
</div>
|
||||
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
|
||||
{label}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
|
||||
>
|
||||
{brand}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Button } from '../../base/button';
|
||||
import { cn } from '../../tools';
|
||||
import type { PublicCourseOrgData, PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
import { ModeSwitcher } from '../../base/dark-mode';
|
||||
|
||||
interface Props {
|
||||
sections: PublicCourseSidebarSection[];
|
||||
@@ -120,7 +121,7 @@
|
||||
<div class={cn('ui:min-h-dvh ui:bg-background ui:text-foreground', className)}>
|
||||
<!--
|
||||
Top header. Sticky so it stays visible as guests scroll long lessons.
|
||||
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in]
|
||||
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in] [Theme]
|
||||
Height intentionally compact (h-12) to maximize content vertical space.
|
||||
-->
|
||||
<header
|
||||
@@ -168,6 +169,7 @@
|
||||
</Button>
|
||||
<Button href={signInHref} variant="default" size="sm">{signInLabel}</Button>
|
||||
{/if}
|
||||
<ModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -186,7 +188,7 @@
|
||||
{sections}
|
||||
{activeSlug}
|
||||
{hrefFor}
|
||||
{onItemClick}
|
||||
onItemClick={hrefFor ? undefined : onItemClick}
|
||||
{showPoweredBy}
|
||||
{courseSlug}
|
||||
orgSlug={org?.siteName ?? org?.name ?? null}
|
||||
@@ -204,7 +206,7 @@
|
||||
prev={prevItem}
|
||||
next={nextItem}
|
||||
{hrefFor}
|
||||
onNavigate={(item) => onItemClick?.(item)}
|
||||
onNavigate={hrefFor ? undefined : (item) => onItemClick?.(item)}
|
||||
prevLabel={footerPrevLabel}
|
||||
nextLabel={footerNextLabel}
|
||||
/>
|
||||
@@ -236,7 +238,9 @@
|
||||
{poweredByLabel}
|
||||
{poweredByBrand}
|
||||
onItemClick={(item) => {
|
||||
onItemClick?.(item);
|
||||
if (!hrefFor) {
|
||||
onItemClick?.(item);
|
||||
}
|
||||
sheetOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,12 +29,29 @@
|
||||
|
||||
const titleClass = $derived(
|
||||
cn(
|
||||
'ui:text-wrap ui:transition-colors',
|
||||
'ui:text-wrap ui:transition-colors ui:text-[13px]',
|
||||
'ui:group-hover:text-primary',
|
||||
active ? 'ui:text-primary' : 'ui:text-foreground/70 ui:group-hover:text-foreground',
|
||||
locked && !active && 'ui:text-muted-foreground'
|
||||
)
|
||||
);
|
||||
|
||||
// function handleAnchorClick(event: MouseEvent) {
|
||||
// if (
|
||||
// !onClick ||
|
||||
// event.defaultPrevented ||
|
||||
// event.button !== 0 ||
|
||||
// event.metaKey ||
|
||||
// event.ctrlKey ||
|
||||
// event.shiftKey ||
|
||||
// event.altKey
|
||||
// ) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// event.preventDefault();
|
||||
// onClick();
|
||||
// }
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
@@ -43,7 +60,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} class={rowClass} aria-current={active ? 'page' : undefined} onclick={onClick}>
|
||||
<a {href} class={rowClass} aria-current={active ? 'page' : undefined}>
|
||||
{@render body()}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { mergeProps } from 'bits-ui';
|
||||
import * as Tooltip from '../../base/tooltip';
|
||||
import { cn } from '../../tools';
|
||||
import PoweredBy from './powered-by.svelte';
|
||||
import SidebarRow from './sidebar-row.svelte';
|
||||
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
|
||||
|
||||
interface Props {
|
||||
// Sections
|
||||
sections: PublicCourseSidebarSection[];
|
||||
/** Slug of the active item. Matches against `item.slug`. */
|
||||
activeSlug?: string | null;
|
||||
@@ -43,27 +46,47 @@
|
||||
|
||||
<div class={cn('ui:flex ui:h-full ui:flex-col ui:bg-sidebar', className)}>
|
||||
<nav class="ui:flex ui:flex-1 ui:flex-col ui:gap-6 ui:py-6 ui:px-2" aria-label="Course outline">
|
||||
{#each sections as section (section.id)}
|
||||
<div class="ui:flex ui:flex-col ui:gap-2">
|
||||
<h2
|
||||
class="ui:px-3 ui:text-xs ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:tracking-wider ui:transition-colors ui:whitespace-nowrap"
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
<div class="ui:flex ui:flex-col">
|
||||
{#each section.items as item, index (item.id)}
|
||||
<SidebarRow
|
||||
number={index + 1}
|
||||
title={item.title}
|
||||
active={item.slug === activeSlug}
|
||||
locked={!item.isUnlocked}
|
||||
href={hrefFor?.(item)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
{#each sections as section (section.id)}
|
||||
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
|
||||
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span
|
||||
{...mergeProps(
|
||||
{
|
||||
class: cn(
|
||||
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
|
||||
)
|
||||
},
|
||||
props
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
|
||||
{section.title}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</h2>
|
||||
<div class="ui:flex ui:flex-col">
|
||||
{#each section.items as item, index (item.id)}
|
||||
<SidebarRow
|
||||
number={index + 1}
|
||||
title={item.title}
|
||||
active={item.slug === activeSlug}
|
||||
locked={!item.isUnlocked}
|
||||
href={hrefFor?.(item)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</Tooltip.Provider>
|
||||
</nav>
|
||||
|
||||
{#if showPoweredBy}
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
|
||||
import type { ExerciseQuestionLabels, ExerciseQuestionModel } from '@cio/question-types';
|
||||
|
||||
/** Background motion behind the public course marketing callout. */
|
||||
export type PublicCourseCalloutAnimation = 'dotted' | 'waves' | 'none';
|
||||
|
||||
export interface PublicCourseCalloutData {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
animation?: PublicCourseCalloutAnimation;
|
||||
}
|
||||
|
||||
/** Org / creator info shown in the top header (logo, name) of public course pages. */
|
||||
@@ -52,6 +56,16 @@ export interface PublicLessonViewData {
|
||||
video: {
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
metadata?: {
|
||||
svid?: string;
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
};
|
||||
transcript?: {
|
||||
vttUrl: string;
|
||||
vttUrlExpiresAt: string;
|
||||
language: string;
|
||||
} | null;
|
||||
} | null;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
@@ -23,16 +23,18 @@
|
||||
</style></head><body>${rawSvg}</body></html>`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each segments as segment}
|
||||
{#if segment.type === 'html'}
|
||||
{@html segment.content}
|
||||
{:else}
|
||||
{@const dims = svgDimensions(segment.content)}
|
||||
<iframe
|
||||
sandbox=""
|
||||
srcdoc={svgSrcdoc(segment.content)}
|
||||
title="Embedded diagram"
|
||||
style="border:none;overflow:hidden;width:{dims.width};height:{dims.height}"
|
||||
></iframe>
|
||||
{/if}
|
||||
|
||||
{#key content}
|
||||
{#each segments as segment, i (i)}
|
||||
{#if segment.type === 'html'}
|
||||
{@html segment.content}
|
||||
{:else}
|
||||
{@const dims = svgDimensions(segment.content)}
|
||||
<iframe
|
||||
sandbox=""
|
||||
srcdoc={svgSrcdoc(segment.content)}
|
||||
title="Embedded diagram"
|
||||
style="border:none;overflow:hidden;width:{dims.width};height:{dims.height}"
|
||||
></iframe>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
bgColor?: string;
|
||||
isAIEnabled?: boolean;
|
||||
initAIPrompt?: string;
|
||||
aiAlignPopover?: string;
|
||||
labelAction?: import('svelte').Snippet;
|
||||
iconbutton?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
@@ -26,6 +24,7 @@
|
||||
isRequired = false,
|
||||
className = '',
|
||||
bgColor = '',
|
||||
labelAction,
|
||||
iconbutton,
|
||||
value = $bindable(),
|
||||
...textareaProps
|
||||
@@ -34,12 +33,15 @@
|
||||
|
||||
<Field.Field class={className}>
|
||||
{#if label}
|
||||
<Field.Label class={labelClassName}>
|
||||
{label}
|
||||
{#if isRequired}
|
||||
<span class="ui:text-red-700">*</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Label class={labelClassName}>
|
||||
{label}
|
||||
{#if isRequired}
|
||||
<span class="ui:text-red-700">*</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
{@render labelAction?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Textarea
|
||||
|
||||
Reference in New Issue
Block a user