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.
This commit is contained in:
@@ -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]]
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Vendored
+5
-3
@@ -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 & {};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<{}>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user