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/9'),
() => import('./nodes/10'),
() => import('./nodes/11')
() => import('./nodes/11'),
() => import('./nodes/12')
];
export const server_loads = [0];
@@ -24,8 +25,9 @@ export const dictionary = {
"/notifications": [~7],
"/profile": [8],
"/training": [~9],
"/users": [~10],
"/users/[id]": [~11],
"/training/[id]": [~10],
"/users": [~11],
"/users/[id]": [~12],
"/[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",
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() {
+5 -3
View File
@@ -27,9 +27,10 @@ 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" | "/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(): {
"/api/notifications/[id]": { id: string };
"/training/[id]": { id: string };
"/users/[id]": { id: string };
"/[site]": { site: string }
};
@@ -45,12 +46,13 @@ declare module "$app/types" {
"/logout": Record<string, never>;
"/notifications": Record<string, never>;
"/profile": Record<string, never>;
"/training": Record<string, never>;
"/training": { id?: string };
"/training/[id]": { id: string };
"/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" | "/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']>}`;
Asset(): string & {};
}
+4
View File
@@ -34,6 +34,10 @@
"src/routes/training/+page.server.ts",
"src/routes/+layout.server.ts"
],
"/training/[id]": [
"src/routes/training/[id]/+page.server.ts",
"src/routes/+layout.server.ts"
],
"/users": [
"src/routes/users/+page.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;
export type Snapshot<T = any> = Kit.Snapshot<T>;
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 LayoutServerParentData = EnsureDefined<{}>;
type LayoutParentData = EnsureDefined<{}>;
+16 -12
View File
@@ -9,8 +9,10 @@ type TrainingVideo = {
videoUrl: string;
};
const VIDEO_COLLECTION = 'year';
const VIDEO_FIELD = 'Media';
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 }) => {
const user = locals.user;
@@ -27,14 +29,16 @@ export const load: PageServerLoad = async ({ locals }) => {
}
const videos: TrainingVideo[] = records.map((record: any) => {
const fileName = record[VIDEO_FIELD];
const videoUrl = fileName
? locals.pb.files.getUrl(record, fileName)
: '';
let videoUrl = (record[VIDEO_LINK_FIELD] as string) ?? '';
// 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 {
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,
created: record.created as string,
videoUrl
@@ -67,7 +71,7 @@ export const actions: Actions = {
const title = String(formData.get('title') ?? '').trim();
const description = String(formData.get('description') ?? '').trim();
const file = formData.get('video');
const link = String(formData.get('link') ?? '').trim();
if (!title) {
return fail(400, {
@@ -76,10 +80,10 @@ export const actions: Actions = {
});
}
if (!(file instanceof File)) {
if (!link) {
return fail(400, {
error: 'Please choose a video file to upload.',
values: { title, description }
error: 'YouTube link is required.',
values: { title, description, link }
});
}
@@ -88,7 +92,7 @@ export const actions: Actions = {
if (description) {
createData.set('description', description);
}
createData.set(VIDEO_FIELD, file);
createData.set(VIDEO_LINK_FIELD, link);
try {
await locals.pb.collection(VIDEO_COLLECTION).create(createData);
+71 -18
View File
@@ -16,6 +16,7 @@
values?: {
title?: string;
description?: string;
link?: string;
};
} | null;
@@ -34,13 +35,52 @@
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>
<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">
<main class="flex min-h-0 flex-1 flex-col items-center p-4 sm:p-6">
<div class="flex w-full max-w-5xl flex-col gap-4 sm:gap-6">
<a href="/" class="self-start">
<Button variant="ghost" size="sm">← Base</Button>
</a>
@@ -53,14 +93,14 @@
<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.Title>Add training video</Card.Title>
<Card.Description>Add a new YouTube 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">
<form method="POST">
<input type="hidden" name="/training" value="1" />
<div class="space-y-2">
<label class="text-sm font-medium" for="title">Title</label>
@@ -82,10 +122,17 @@
/>
</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 />
<label class="text-sm font-medium" for="link">YouTube link</label>
<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">
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>
</div>
<div class="mt-4 flex justify-end">
@@ -99,31 +146,36 @@
<section class="space-y-3">
{#if videos.length === 0}
<p class="text-muted-foreground text-sm">No training videos yet.</p>
<p class="text-muted-foreground text-sm text-center">No training videos yet.</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div class="grid gap-4 grid-cols-1">
{#each videos as video (video.id)}
<Card.Root class="flex flex-col">
<Card.Header class="pb-3">
<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">
<video
src={video.videoUrl}
controls
<iframe
src={toEmbedUrl(video.videoUrl)}
class="h-full w-full"
>
Your browser does not support the video tag.
<track kind="captions" srclang="en" label="English captions" />
</video>
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 file is missing.</p>
<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>
@@ -134,5 +186,6 @@
</div>
{/if}
</section>
</div>
</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>