Update training page to support YouTube video links. Refactored video upload handling to accept YouTube URLs, improved layout for better user experience, and added new route type definitions for training videos. Enhanced video display with embedded player support.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s

This commit is contained in:
eewing
2026-03-17 09:13:27 -05:00
parent 59056d3f41
commit a37575d810
9 changed files with 288 additions and 74 deletions
+5 -3
View File
@@ -12,7 +12,8 @@ export const nodes = [
() => import('./nodes/8'), () => import('./nodes/8'),
() => import('./nodes/9'), () => import('./nodes/9'),
() => import('./nodes/10'), () => import('./nodes/10'),
() => import('./nodes/11') () => import('./nodes/11'),
() => import('./nodes/12')
]; ];
export const server_loads = [0]; export const server_loads = [0];
@@ -24,8 +25,9 @@ export const dictionary = {
"/notifications": [~7], "/notifications": [~7],
"/profile": [8], "/profile": [8],
"/training": [~9], "/training": [~9],
"/users": [~10], "/training/[id]": [~10],
"/users/[id]": [~11], "/users": [~11],
"/users/[id]": [~12],
"/[site]": [~4,[2]] "/[site]": [~4,[2]]
}; };
+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", 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" 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: "1mkjc4f" version_hash: "n9d9oa"
}; };
export async function get_hooks() { export async function get_hooks() {
+5 -3
View File
@@ -27,9 +27,10 @@ export {};
declare module "$app/types" { declare module "$app/types" {
export interface AppTypes { export interface AppTypes {
RouteId(): "/" | "/api" | "/api/darkmode" | "/api/notifications" | "/api/notifications/[id]" | "/api/sso" | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/users" | "/users/[id]" | "/[site]"; RouteId(): "/" | "/api" | "/api/darkmode" | "/api/notifications" | "/api/notifications/[id]" | "/api/sso" | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/training/[id]" | "/users" | "/users/[id]" | "/[site]";
RouteParams(): { RouteParams(): {
"/api/notifications/[id]": { id: string }; "/api/notifications/[id]": { id: string };
"/training/[id]": { id: string };
"/users/[id]": { id: string }; "/users/[id]": { id: string };
"/[site]": { site: string } "/[site]": { site: string }
}; };
@@ -45,12 +46,13 @@ declare module "$app/types" {
"/logout": Record<string, never>; "/logout": Record<string, never>;
"/notifications": Record<string, never>; "/notifications": Record<string, never>;
"/profile": Record<string, never>; "/profile": Record<string, never>;
"/training": Record<string, never>; "/training": { id?: string };
"/training/[id]": { id: string };
"/users": { id?: string }; "/users": { id?: string };
"/users/[id]": { id: string }; "/users/[id]": { id: string };
"/[site]": { site: string } "/[site]": { site: string }
}; };
Pathname(): "/" | "/api/darkmode" | "/api/notifications" | `/api/notifications/${string}` & {} | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/users" | `/users/${string}` & {} | `/${string}` & {}; Pathname(): "/" | "/api/darkmode" | "/api/notifications" | `/api/notifications/${string}` & {} | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | `/training/${string}` & {} | "/users" | `/users/${string}` & {} | `/${string}` & {};
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`; ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
Asset(): string & {}; Asset(): string & {};
} }
+4
View File
@@ -34,6 +34,10 @@
"src/routes/training/+page.server.ts", "src/routes/training/+page.server.ts",
"src/routes/+layout.server.ts" "src/routes/+layout.server.ts"
], ],
"/training/[id]": [
"src/routes/training/[id]/+page.server.ts",
"src/routes/+layout.server.ts"
],
"/users": [ "/users": [
"src/routes/users/+page.server.ts", "src/routes/users/+page.server.ts",
"src/routes/+layout.server.ts" "src/routes/+layout.server.ts"
+1 -1
View File
@@ -12,7 +12,7 @@ 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; 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>; export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageParentData = EnsureDefined<LayoutData>; type PageParentData = EnsureDefined<LayoutData>;
type LayoutRouteId = RouteId | "/" | "/[site]" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/users" | "/users/[id]" | null type LayoutRouteId = RouteId | "/" | "/[site]" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/training/[id]" | "/users" | "/users/[id]" | null
type LayoutParams = RouteParams & { site?: string; id?: string } type LayoutParams = RouteParams & { site?: string; id?: string }
type LayoutServerParentData = EnsureDefined<{}>; type LayoutServerParentData = EnsureDefined<{}>;
type LayoutParentData = EnsureDefined<{}>; type LayoutParentData = EnsureDefined<{}>;
+16 -12
View File
@@ -9,8 +9,10 @@ type TrainingVideo = {
videoUrl: string; videoUrl: string;
}; };
const VIDEO_COLLECTION = 'year'; const VIDEO_COLLECTION = 'media';
const VIDEO_FIELD = 'Media'; const VIDEO_LINK_FIELD = 'Link';
const VIDEO_FILE_FIELD = 'Media';
const VIDEO_TITLE_FIELD = 'Title';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const user = locals.user; const user = locals.user;
@@ -27,14 +29,16 @@ export const load: PageServerLoad = async ({ locals }) => {
} }
const videos: TrainingVideo[] = records.map((record: any) => { const videos: TrainingVideo[] = records.map((record: any) => {
const fileName = record[VIDEO_FIELD]; let videoUrl = (record[VIDEO_LINK_FIELD] as string) ?? '';
const videoUrl = fileName
? locals.pb.files.getUrl(record, fileName) // Fallback: if there is no link but there is a file, build a file URL
: ''; if (!videoUrl && record[VIDEO_FILE_FIELD]) {
videoUrl = locals.pb.files.getUrl(record, record[VIDEO_FILE_FIELD]);
}
return { return {
id: record.id as string, id: record.id as string,
title: (record.title as string) ?? 'Untitled video', title: (record[VIDEO_TITLE_FIELD] as string) ?? (record.title as string) ?? 'Untitled video',
description: (record.description as string) ?? null, description: (record.description as string) ?? null,
created: record.created as string, created: record.created as string,
videoUrl videoUrl
@@ -67,7 +71,7 @@ export const actions: Actions = {
const title = String(formData.get('title') ?? '').trim(); const title = String(formData.get('title') ?? '').trim();
const description = String(formData.get('description') ?? '').trim(); const description = String(formData.get('description') ?? '').trim();
const file = formData.get('video'); const link = String(formData.get('link') ?? '').trim();
if (!title) { if (!title) {
return fail(400, { return fail(400, {
@@ -76,10 +80,10 @@ export const actions: Actions = {
}); });
} }
if (!(file instanceof File)) { if (!link) {
return fail(400, { return fail(400, {
error: 'Please choose a video file to upload.', error: 'YouTube link is required.',
values: { title, description } values: { title, description, link }
}); });
} }
@@ -88,7 +92,7 @@ export const actions: Actions = {
if (description) { if (description) {
createData.set('description', description); createData.set('description', description);
} }
createData.set(VIDEO_FIELD, file); createData.set(VIDEO_LINK_FIELD, link);
try { try {
await locals.pb.collection(VIDEO_COLLECTION).create(createData); await locals.pb.collection(VIDEO_COLLECTION).create(createData);
+107 -54
View File
@@ -16,6 +16,7 @@
values?: { values?: {
title?: string; title?: string;
description?: string; description?: string;
link?: string;
}; };
} | null; } | null;
@@ -34,33 +35,72 @@
year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
}); });
} }
function toEmbedUrl(url: string) {
if (!url) return '';
try {
const u = new URL(url);
// Short youtu.be/<id>
if (u.hostname === 'youtu.be') {
return `https://www.youtube.com/embed${u.pathname}`;
}
// www.youtube.com/watch?v=<id> or youtube.com/watch?v=<id>
if (u.hostname.includes('youtube.com')) {
if (u.pathname === '/watch' && u.searchParams.get('v')) {
const id = u.searchParams.get('v');
return `https://www.youtube.com/embed/${id}`;
}
// /embed/<id> already in embed form
if (u.pathname.startsWith('/embed/')) {
return url;
}
// Shorts: /shorts/<id>
if (u.pathname.startsWith('/shorts/')) {
const id = u.pathname.split('/')[2];
if (id) {
return `https://www.youtube.com/embed/${id}`;
}
}
}
} catch {
// fall through to raw url
}
return url;
}
</script> </script>
<svelte:head> <svelte:head>
<title>Training — Base</title> <title>Training — Base</title>
</svelte:head> </svelte:head>
<main class="flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-6 sm:p-6"> <main class="flex min-h-0 flex-1 flex-col items-center p-4 sm:p-6">
<a href="/" class="self-start"> <div class="flex w-full max-w-5xl flex-col gap-4 sm:gap-6">
<Button variant="ghost" size="sm">← Base</Button> <a href="/" class="self-start">
</a> <Button variant="ghost" size="sm">← Base</Button>
</a>
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-semibold">Training</h1> <h1 class="text-2xl font-semibold">Training</h1>
</div> </div>
{#if canUpload} {#if canUpload}
<section class="space-y-3"> <section class="space-y-3">
<Card.Root> <Card.Root>
<Card.Header class="pb-2"> <Card.Header class="pb-2">
<Card.Title>Upload training video</Card.Title> <Card.Title>Add training video</Card.Title>
<Card.Description>Add a new video for your team.</Card.Description> <Card.Description>Add a new YouTube video for your team.</Card.Description>
</Card.Header> </Card.Header>
<Card.Content class="space-y-3"> <Card.Content class="space-y-3">
{#if form?.error} {#if form?.error}
<p class="text-destructive text-sm">{form.error}</p> <p class="text-destructive text-sm">{form.error}</p>
{/if} {/if}
<form method="POST" enctype="multipart/form-data"> <form method="POST">
<input type="hidden" name="/training" value="1" /> <input type="hidden" name="/training" value="1" />
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium" for="title">Title</label> <label class="text-sm font-medium" for="title">Title</label>
@@ -82,10 +122,17 @@
/> />
</div> </div>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
<label class="text-sm font-medium" for="video">Video file</label> <label class="text-sm font-medium" for="link">YouTube link</label>
<Input id="video" name="video" type="file" accept="video/*" required /> <Input
id="link"
name="link"
type="url"
required
value={form?.values?.link ?? ''}
placeholder="https://www.youtube.com/watch?v=..."
/>
<p class="text-muted-foreground text-xs"> <p class="text-muted-foreground text-xs">
Upload an MP4 or WebM file. Size limits follow PocketBase settings. Paste a full YouTube URL (for example, https://www.youtube.com/watch?v=...).
</p> </p>
</div> </div>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
@@ -94,45 +141,51 @@
</form> </form>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
</section> </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} {/if}
</section>
<section class="space-y-3">
{#if videos.length === 0}
<p class="text-muted-foreground text-sm text-center">No training videos yet.</p>
{:else}
<div class="grid gap-4 grid-cols-1">
{#each videos as video (video.id)}
<Card.Root class="flex flex-col">
<Card.Header class="flex items-start justify-between gap-2 pb-3">
<div class="min-w-0">
<Card.Title class="truncate">{video.title}</Card.Title>
<Card.Description class="text-xs">{formatDate(video.created)}</Card.Description>
</div>
<a href={`/training/${video.id}`} aria-label="Open training video">
<Button variant="outline" size="sm">Open</Button>
</a>
</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">
<iframe
src={toEmbedUrl(video.videoUrl)}
class="h-full w-full"
loading="lazy"
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</div>
{:else}
<p class="text-muted-foreground text-sm">Video link 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>
</div>
</main> </main>
+46
View File
@@ -0,0 +1,46 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
type TrainingVideo = {
id: string;
title: string;
description: string | null;
created: string;
videoUrl: string;
};
const VIDEO_COLLECTION = 'media';
const VIDEO_LINK_FIELD = 'Link';
const VIDEO_FILE_FIELD = 'Media';
const VIDEO_TITLE_FIELD = 'Title';
export const load: PageServerLoad = async ({ locals, params }) => {
const { id } = params;
let record: any;
try {
record = await locals.pb.collection(VIDEO_COLLECTION).getOne(id);
} catch (e) {
throw error(404, 'Training video not found');
}
let videoUrl = (record[VIDEO_LINK_FIELD] as string) ?? '';
if (!videoUrl && record[VIDEO_FILE_FIELD]) {
videoUrl = locals.pb.files.getUrl(record, record[VIDEO_FILE_FIELD]);
}
const video: TrainingVideo = {
id: record.id as string,
title: (record[VIDEO_TITLE_FIELD] as string) ?? (record.title as string) ?? 'Untitled video',
description: (record.description as string) ?? null,
created: record.created as string,
videoUrl
};
return {
video
};
};
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
type TrainingVideo = {
id: string;
title: string;
description: string | null;
created: string;
videoUrl: string;
};
let { data }: { data: { video: TrainingVideo } } = $props();
const video = $derived(data.video);
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
});
}
function toEmbedUrl(url: string) {
if (!url) return '';
try {
const u = new URL(url);
if (u.hostname === 'youtu.be') {
return `https://www.youtube.com/embed${u.pathname}`;
}
if (u.hostname.includes('youtube.com')) {
if (u.pathname === '/watch' && u.searchParams.get('v')) {
const id = u.searchParams.get('v');
return `https://www.youtube.com/embed/${id}`;
}
if (u.pathname.startsWith('/embed/')) {
return url;
}
if (u.pathname.startsWith('/shorts/')) {
const id = u.pathname.split('/')[2];
if (id) {
return `https://www.youtube.com/embed/${id}`;
}
}
}
} catch {
// fall through
}
return url;
}
</script>
<svelte:head>
<title>{video.title || 'Training video'} — Base</title>
</svelte:head>
<main class="flex min-h-0 flex-1 flex-col items-center p-4 sm:p-6">
<div class="flex w-full max-w-4xl flex-col gap-4 sm:gap-6">
<div class="flex items-center justify-between gap-3">
<a href="/training">
<Button variant="ghost" size="sm">← All training</Button>
</a>
<p class="text-muted-foreground text-xs">{formatDate(video.created)}</p>
</div>
<Card.Root class="flex flex-1 flex-col">
<Card.Header class="pb-3">
<Card.Title>{video.title}</Card.Title>
{#if video.description}
<Card.Description>{video.description}</Card.Description>
{/if}
</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">
<iframe
src={toEmbedUrl(video.videoUrl)}
class="h-full w-full"
loading="lazy"
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</div>
{:else}
<p class="text-muted-foreground text-sm">Video link is missing.</p>
{/if}
</Card.Content>
</Card.Root>
</div>
</main>