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.
This commit is contained in:
@@ -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 @@
|
||||
export { default as component } from "../../../../src/routes/logout/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/[site]/+page.svelte";
|
||||
@@ -1 +1 @@
|
||||
export { default as component } from "../../../../src/routes/profile/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
@@ -1 +1 @@
|
||||
export { default as component } from "../../../../src/routes/users/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/logout/+page.svelte";
|
||||
@@ -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 @@
|
||||
export { default as component } from "../../../../src/routes/[site]/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/profile/+page.svelte";
|
||||
@@ -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() {
|
||||
|
||||
Vendored
+3
-2
@@ -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 & {};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"}]}
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ declare global {
|
||||
tasgrid?: boolean;
|
||||
stackq?: boolean;
|
||||
hrm?: boolean;
|
||||
training_admin?: boolean;
|
||||
} | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
Open {data.title}
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
class="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
← Back to Base
|
||||
</a>
|
||||
</main>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user