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/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]]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Vendored
+5
-3
@@ -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 & {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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<{}>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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