Enhance routing and type definitions for training page. Added new training route and updated related components, including navigation links and user data handling. Refactored layout for improved user experience and consistency across site pages.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m31s

This commit is contained in:
eewing
2026-03-16 14:05:11 -05:00
parent 60ac4a8f79
commit 59056d3f41
19 changed files with 310 additions and 74 deletions
+10 -8
View File
@@ -11,20 +11,22 @@ export const nodes = [
() => import('./nodes/7'),
() => import('./nodes/8'),
() => import('./nodes/9'),
() => import('./nodes/10')
() => import('./nodes/10'),
() => import('./nodes/11')
];
export const server_loads = [0];
export const dictionary = {
"/": [3],
"/login": [~10],
"/logout": [~4],
"/notifications": [~9],
"/profile": [5],
"/users": [~6],
"/users/[id]": [~7],
"/[site]": [~8,[2]]
"/login": [~5],
"/logout": [~6],
"/notifications": [~7],
"/profile": [8],
"/training": [~9],
"/users": [~10],
"/users/[id]": [~11],
"/[site]": [~4,[2]]
};
export const hooks = {
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/logout/+page.svelte";
export { default as component } from "../../../../src/routes/[site]/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/profile/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/users/+page.svelte";
export { default as component } from "../../../../src/routes/logout/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/users/[id]/+page.svelte";
export { default as component } from "../../../../src/routes/notifications/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/[site]/+page.svelte";
export { default as component } from "../../../../src/routes/profile/+page.svelte";
+1 -1
View File
@@ -24,7 +24,7 @@ export const options = {
app: ({ head, body, assets, nonce, env }) => "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, viewport-fit=cover\" />\n <meta name=\"theme-color\" content=\"#0f172a\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
},
version_hash: "3lzvrw"
version_hash: "1mkjc4f"
};
export async function get_hooks() {
+3 -2
View File
@@ -27,7 +27,7 @@ export {};
declare module "$app/types" {
export interface AppTypes {
RouteId(): "/" | "/api" | "/api/darkmode" | "/api/notifications" | "/api/notifications/[id]" | "/api/sso" | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/users" | "/users/[id]" | "/[site]";
RouteId(): "/" | "/api" | "/api/darkmode" | "/api/notifications" | "/api/notifications/[id]" | "/api/sso" | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/users" | "/users/[id]" | "/[site]";
RouteParams(): {
"/api/notifications/[id]": { id: string };
"/users/[id]": { id: string };
@@ -45,11 +45,12 @@ declare module "$app/types" {
"/logout": Record<string, never>;
"/notifications": Record<string, never>;
"/profile": Record<string, never>;
"/training": Record<string, never>;
"/users": { id?: string };
"/users/[id]": { id: string };
"/[site]": { site: string }
};
Pathname(): "/" | "/api/darkmode" | "/api/notifications" | `/api/notifications/${string}` & {} | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/users" | `/users/${string}` & {} | `/${string}` & {};
Pathname(): "/" | "/api/darkmode" | "/api/notifications" | `/api/notifications/${string}` & {} | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/users" | `/users/${string}` & {} | `/${string}` & {};
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
Asset(): string & {};
}
+4
View File
@@ -30,6 +30,10 @@
"/profile": [
"src/routes/+layout.server.ts"
],
"/training": [
"src/routes/training/+page.server.ts",
"src/routes/+layout.server.ts"
],
"/users": [
"src/routes/users/+page.server.ts",
"src/routes/+layout.server.ts"
+2 -2
View File
@@ -12,8 +12,8 @@ type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageParentData = EnsureDefined<LayoutData>;
type LayoutRouteId = RouteId | "/" | "/logout" | "/profile" | "/users" | "/users/[id]" | "/[site]" | "/notifications" | "/login" | null
type LayoutParams = RouteParams & { id?: string; site?: string }
type LayoutRouteId = RouteId | "/" | "/[site]" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/users" | "/users/[id]" | null
type LayoutParams = RouteParams & { site?: string; id?: string }
type LayoutServerParentData = EnsureDefined<{}>;
type LayoutParentData = EnsureDefined<{}>;
+1 -1
View File
@@ -1 +1 @@
{"compilerOptions":{"css":"external","dev":true,"hmr":true},"configFile":false,"extensions":[".svelte"],"preprocess":[{"name":"vite-preprocess","style":"async ({ attributes, content, filename = \"\" }) => {\n const ext = attributes.lang ? `.${attributes.lang}` : \".css\";\n if (attributes.lang && !isCSSRequest(ext))\n return;\n if (!cssTransform)\n cssTransform = createCssTransform(style, config).then((t) => cssTransform = t);\n const transform = await cssTransform, suffix = `${lang_sep}${ext}`, moduleId = `${filename}${suffix}`, { code, map, deps } = await transform(content, moduleId);\n removeLangSuffix(map, suffix);\n mapToRelative(map, filename);\n const dependencies = deps ? Array.from(deps).filter((d) => !d.endsWith(suffix)) : void 0;\n return {\n code,\n map: map ?? void 0,\n dependencies\n };\n }"},{"script":"({ content, filename }) => {\n if (!filename)\n return;\n const basename = path.basename(filename);\n if (basename.startsWith(\"+page.\") || basename.startsWith(\"+layout.\")) {\n const match = content.match(options_regex);\n if (match && match.index !== void 0 && !should_ignore(content, match.index)) {\n const fixed = basename.replace(\".svelte\", \"(.server).js/ts\"), message = `\n${colors.bold().red(path.relative(\".\", filename))}\n\\`${match[1]}\\` will be ignored \\u2014 move it to ${fixed} instead. See https://svelte.dev/docs/kit/page-options for more information.`;\n if (!warned.has(message)) {\n console.log(message);\n warned.add(message);\n }\n }\n }\n }","markup":"({ content, filename }) => {\n if (!filename)\n return;\n if (path.basename(filename).startsWith(\"+layout.\") && !has_children(content, isSvelte5Plus())) {\n const message = `\n${colors.bold().red(path.relative(\".\", filename))}\n\\`<slot />\\`${isSvelte5Plus() ? \" or `{@render ...}` tag\" : \"\"}` + \" missing \\u2014 inner content will not be rendered\";\n if (!warned.has(message)) {\n console.log(message);\n warned.add(message);\n }\n }\n }"}]}
{"compilerOptions":{"css":"external","dev":true,"hmr":true},"configFile":false,"extensions":[".svelte"],"preprocess":[{"name":"vite-preprocess","style":"async ({ attributes, content, filename = '' }) => {\n\t\tconst ext = attributes.lang ? `.${attributes.lang}` : '.css';\n\t\tif (attributes.lang && !isCSSRequest(ext)) return;\n\t\tif (!cssTransform) {\n\t\t\tcssTransform = createCssTransform(style, config).then((t) => (cssTransform = t));\n\t\t}\n\t\tconst transform = await cssTransform;\n\t\tconst suffix = `${lang_sep}${ext}`;\n\t\tconst moduleId = `${filename}${suffix}`;\n\t\tconst { code, map, deps } = await transform(content, moduleId);\n\t\tremoveLangSuffix(map, suffix);\n\t\tmapToRelative(map, filename);\n\t\tconst dependencies = deps ? Array.from(deps).filter((d) => !d.endsWith(suffix)) : undefined;\n\t\treturn {\n\t\t\tcode,\n\t\t\tmap: map ?? undefined,\n\t\t\tdependencies\n\t\t};\n\t}"},{"script":"({ content, filename }) => {\n\t\tif (!filename) return;\n\n\t\tconst basename = path.basename(filename);\n\t\tif (basename.startsWith('+page.') || basename.startsWith('+layout.')) {\n\t\t\tconst match = content.match(options_regex);\n\t\t\tif (match && match.index !== undefined && !should_ignore(content, match.index)) {\n\t\t\t\tconst fixed = basename.replace('.svelte', '(.server).js/ts');\n\n\t\t\t\tconst message =\n\t\t\t\t\t`\\n${colors.bold().red(path.relative('.', filename))}\\n` +\n\t\t\t\t\t`\\`${match[1]}\\` will be ignored move it to ${fixed} instead. See https://svelte.dev/docs/kit/page-options for more information.`;\n\n\t\t\t\tif (!warned.has(message)) {\n\t\t\t\t\tconsole.log(message);\n\t\t\t\t\twarned.add(message);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}","markup":"({ content, filename }) => {\n\t\tif (!filename) return;\n\n\t\tconst basename = path.basename(filename);\n\n\t\tif (basename.startsWith('+layout.') && !has_children(content, isSvelte5Plus())) {\n\t\t\tconst message =\n\t\t\t\t`\\n${colors.bold().red(path.relative('.', filename))}\\n` +\n\t\t\t\t`\\`<slot />\\`${isSvelte5Plus() ? ' or `{@render ...}` tag' : ''}` +\n\t\t\t\t' missing inner content will not be rendered';\n\n\t\t\tif (!warned.has(message)) {\n\t\t\t\tconsole.log(message);\n\t\t\t\twarned.add(message);\n\t\t\t}\n\t\t}\n\t}"}]}
+1
View File
@@ -15,6 +15,7 @@ declare global {
tasgrid?: boolean;
stackq?: boolean;
hrm?: boolean;
training_admin?: boolean;
} | null;
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ export function mapUserRecord(r: Record<string, unknown>): UserFromRecord {
prism: bool(r, 'PRISM', 'prism', 'Prism'),
tasgrid: bool(r, 'TasGrid', 'tasgrid'),
stackq: bool(r, 'Stackq', 'stackq'),
hrm: bool(r, 'HRM', 'hrm', 'Hrm')
hrm: bool(r, 'HRM', 'hrm', 'Hrm'),
training_admin: bool(r, 'training_admin', 'TrainingAdmin', 'trainingAdmin')
};
}
+7 -3
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { browser } from '$app/environment';
import { Button } from '$lib/components/ui/button';
import { sites, siteSlugs, type SiteSlug } from '$lib/sites';
import { sites, siteSlugs, type SiteSlug } from '$lib/sites';
import type { LayoutServerLoad } from './$types';
let { data }: { data: App.PageData } = $props();
let { data }: { data: { user: App.Locals['user'] } } = $props();
const visibleSlugs = $derived(
data.user
@@ -27,9 +28,12 @@
{#each visibleSlugs as slug}
{@const site = sites[slug]}
<a href="/{slug}" class="block">
<Button class="h-16 w-full min-h-16" variant="outline">{site.title} | {site.description}</Button>
<Button class="h-16 w-full min-h-16" variant="outline">{site.title}</Button>
</a>
{/each}
<a href="/training" class="block">
<Button class="h-12 w-full" variant="outline">Training</Button>
</a>
<a href="/notifications" class="block">
<Button class="h-12 w-full" variant="outline">Notifications</Button>
</a>
+5 -2
View File
@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { sites, siteSlugs, type SiteSlug } from '$lib/sites';
let { data }: { data: App.PageData } = $props();
let { data }: { data: { user: App.Locals['user'] } } = $props();
const visibleSlugs = $derived(
data.user
@@ -18,10 +18,13 @@
{@const site = sites[slug]}
<a href="/{slug}" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">
{site.title} | {site.description}
{site.title}
</Button>
</a>
{/each}
<a href="/training" class="block">
<Button class="h-12 w-full" variant="outline">Training</Button>
</a>
<a href="/notifications" class="block">
<Button class="h-12 w-full" variant="outline">Notifications</Button>
</a>
+3 -22
View File
@@ -1,9 +1,7 @@
import { sites, type SiteSlug } from '$lib/sites';
import { error } from '@sveltejs/kit';
import * as ssoStore from '$lib/sso-store';
import { randomUUID } from 'crypto';
import { error, redirect } from '@sveltejs/kit';
export function load({ params, locals }) {
export function load({ params }) {
const slug = params.site?.toLowerCase() as SiteSlug | undefined;
const site = slug && slug in sites ? sites[slug as SiteSlug] : null;
@@ -11,22 +9,5 @@ export function load({ params, locals }) {
throw error(404, `Unknown site: ${params.site}`);
}
let ssoToken: string | undefined;
if (locals.user && locals.pb?.authStore?.token) {
try {
const token = locals.pb.authStore.token;
const model = locals.pb.authStore.record as Record<string, unknown> | null;
ssoToken = randomUUID();
ssoStore.set(ssoToken, { token, model });
} catch {
// no sso if export fails
}
}
return {
slug: slug as SiteSlug,
url: site.url,
title: site.title,
ssoToken
};
throw redirect(303, site.url);
}
+18 -26
View File
@@ -1,38 +1,30 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
let { data }: {
data: { slug: string; url: string; title: string; ssoToken?: string };
data: { slug: string; url: string; title: string };
} = $props();
const iframeSrc = $derived(
data.ssoToken
? `${data.url}${data.url.includes('?') ? '&' : '?'}sso=${data.ssoToken}`
: data.url
);
</script>
<svelte:head>
<title>{data.title} — Base</title>
</svelte:head>
<main class="relative flex-1 min-h-0 min-w-0 flex flex-col">
<iframe
id="app-iframe"
title={data.title}
src={iframeSrc}
class="absolute inset-0 w-full h-full border-0 rounded-none"
/>
<div
class="absolute top-2 left-1/2 -translate-x-1/2 z-10 flex items-center justify-center pointer-events-none pt-[env(safe-area-inset-top)]"
aria-hidden="true"
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-4 sm:gap-6 sm:p-6">
<h1 class="text-2xl font-semibold">{data.title}</h1>
<p class="text-muted-foreground text-sm text-center max-w-md">
This app now opens in a separate tab.
</p>
<a
href={data.url}
rel="noreferrer"
target="_blank"
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="pointer-events-auto">
<a href="/">
<Button type="button" variant="secondary" class="shadow-md">
Base
</Button>
Open {data.title}
</a>
<a
href="/"
class="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
← Back to Base
</a>
</div>
</div>
</main>
+109
View File
@@ -0,0 +1,109 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
type TrainingVideo = {
id: string;
title: string;
description: string | null;
created: string;
videoUrl: string;
};
const VIDEO_COLLECTION = 'year';
const VIDEO_FIELD = 'Media';
export const load: PageServerLoad = async ({ locals }) => {
const user = locals.user;
let records: any[] = [];
try {
records = await locals.pb.collection(VIDEO_COLLECTION).getFullList({
sort: '-created'
});
} catch (e) {
console.error('Failed to load training videos from PocketBase', e);
records = [];
}
const videos: TrainingVideo[] = records.map((record: any) => {
const fileName = record[VIDEO_FIELD];
const videoUrl = fileName
? locals.pb.files.getUrl(record, fileName)
: '';
return {
id: record.id as string,
title: (record.title as string) ?? 'Untitled video',
description: (record.description as string) ?? null,
created: record.created as string,
videoUrl
};
});
const canUpload = Boolean(user?.training_admin);
return {
videos,
canUpload
};
};
export const actions: Actions = {
upload: async ({ request, locals }) => {
const user = locals.user;
if (!user) {
throw redirect(303, '/login');
}
if (!user.training_admin) {
return fail(403, {
error: 'You do not have permission to upload training videos.'
});
}
const formData = await request.formData();
const title = String(formData.get('title') ?? '').trim();
const description = String(formData.get('description') ?? '').trim();
const file = formData.get('video');
if (!title) {
return fail(400, {
error: 'Title is required.',
values: { title, description }
});
}
if (!(file instanceof File)) {
return fail(400, {
error: 'Please choose a video file to upload.',
values: { title, description }
});
}
const createData = new FormData();
createData.set('title', title);
if (description) {
createData.set('description', description);
}
createData.set(VIDEO_FIELD, file);
try {
await locals.pb.collection(VIDEO_COLLECTION).create(createData);
} catch (e) {
const message =
e && typeof e === 'object' && 'message' in e
? String((e as { message: string }).message)
: 'Could not upload video.';
return fail(500, {
error: message,
values: { title, description }
});
}
throw redirect(303, '/training');
}
};
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte';
type TrainingVideo = {
id: string;
title: string;
description: string | null;
created: string;
videoUrl: string;
};
type UploadFormState = {
error?: string;
values?: {
title?: string;
description?: string;
};
} | null;
let { data, form }: { data: { videos: TrainingVideo[]; canUpload: boolean }; form: UploadFormState } =
$props();
const videos = $derived(data.videos);
const canUpload = $derived(data.canUpload);
function formatDate(created: string) {
if (!created) return '';
const d = new Date(created);
return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
});
}
</script>
<svelte:head>
<title>Training — Base</title>
</svelte:head>
<main class="flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-6 sm:p-6">
<a href="/" class="self-start">
<Button variant="ghost" size="sm">← Base</Button>
</a>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-semibold">Training</h1>
</div>
{#if canUpload}
<section class="space-y-3">
<Card.Root>
<Card.Header class="pb-2">
<Card.Title>Upload training video</Card.Title>
<Card.Description>Add a new video for your team.</Card.Description>
</Card.Header>
<Card.Content class="space-y-3">
{#if form?.error}
<p class="text-destructive text-sm">{form.error}</p>
{/if}
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="/training" value="1" />
<div class="space-y-2">
<label class="text-sm font-medium" for="title">Title</label>
<Input
id="title"
name="title"
required
value={form?.values?.title ?? ''}
placeholder="Onboarding overview"
/>
</div>
<div class="mt-3 space-y-2">
<label class="text-sm font-medium" for="description">Description</label>
<Input
id="description"
name="description"
value={form?.values?.description ?? ''}
placeholder="Short summary of this training"
/>
</div>
<div class="mt-3 space-y-2">
<label class="text-sm font-medium" for="video">Video file</label>
<Input id="video" name="video" type="file" accept="video/*" required />
<p class="text-muted-foreground text-xs">
Upload an MP4 or WebM file. Size limits follow PocketBase settings.
</p>
</div>
<div class="mt-4 flex justify-end">
<Button type="submit" name="intent" value="upload">Upload</Button>
</div>
</form>
</Card.Content>
</Card.Root>
</section>
{/if}
<section class="space-y-3">
{#if videos.length === 0}
<p class="text-muted-foreground text-sm">No training videos yet.</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each videos as video (video.id)}
<Card.Root class="flex flex-col">
<Card.Header class="pb-3">
<Card.Title class="truncate">{video.title}</Card.Title>
<Card.Description class="text-xs">{formatDate(video.created)}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-1 flex-col gap-3">
{#if video.videoUrl}
<div class="relative w-full overflow-hidden rounded-md bg-black/80">
<div class="aspect-video">
<video
src={video.videoUrl}
controls
class="h-full w-full"
>
Your browser does not support the video tag.
<track kind="captions" srclang="en" label="English captions" />
</video>
</div>
</div>
{:else}
<p class="text-muted-foreground text-sm">Video file is missing.</p>
{/if}
{#if video.description}
<p class="text-muted-foreground text-sm line-clamp-3">{video.description}</p>
{/if}
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</section>
</main>