Enhance training route and admin functionality. Added new admin route for training, updated type definitions to include admin checks, and modified training page to conditionally display admin link based on user permissions. Improved data handling for training videos and user roles.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m5s

This commit is contained in:
eewing
2026-03-18 07:59:13 -05:00
parent 89f9f04504
commit ab226470a1
9 changed files with 222 additions and 9 deletions
+5 -3
View File
@@ -13,7 +13,8 @@ export const nodes = [
() => import('./nodes/9'), () => import('./nodes/9'),
() => import('./nodes/10'), () => import('./nodes/10'),
() => import('./nodes/11'), () => import('./nodes/11'),
() => import('./nodes/12') () => import('./nodes/12'),
() => import('./nodes/13')
]; ];
export const server_loads = [0]; export const server_loads = [0];
@@ -25,9 +26,10 @@ export const dictionary = {
"/notifications": [~7], "/notifications": [~7],
"/profile": [8], "/profile": [8],
"/training": [~9], "/training": [~9],
"/training/admin": [~11],
"/training/[id]": [~10], "/training/[id]": [~10],
"/users": [~11], "/users": [~12],
"/users/[id]": [~12], "/users/[id]": [~13],
"/[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: "n9d9oa" version_hash: "sskapp"
}; };
export async function get_hooks() { export async function get_hooks() {
+3 -2
View File
@@ -27,7 +27,7 @@ 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" | "/training/[id]" | "/users" | "/users/[id]" | "/[site]"; RouteId(): "/" | "/api" | "/api/darkmode" | "/api/notifications" | "/api/notifications/[id]" | "/api/sso" | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/training/admin" | "/training/[id]" | "/users" | "/users/[id]" | "/[site]";
RouteParams(): { RouteParams(): {
"/api/notifications/[id]": { id: string }; "/api/notifications/[id]": { id: string };
"/training/[id]": { id: string }; "/training/[id]": { id: string };
@@ -47,12 +47,13 @@ declare module "$app/types" {
"/notifications": Record<string, never>; "/notifications": Record<string, never>;
"/profile": Record<string, never>; "/profile": Record<string, never>;
"/training": { id?: string }; "/training": { id?: string };
"/training/admin": Record<string, never>;
"/training/[id]": { 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" | `/training/${string}` & {} | "/users" | `/users/${string}` & {} | `/${string}` & {}; Pathname(): "/" | "/api/darkmode" | "/api/notifications" | `/api/notifications/${string}` & {} | "/api/sso/exchange" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/training/admin" | `/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/admin": [
"src/routes/training/admin/+page.server.ts",
"src/routes/+layout.server.ts"
],
"/training/[id]": [ "/training/[id]": [
"src/routes/training/[id]/+page.server.ts", "src/routes/training/[id]/+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" | "/training/[id]" | "/users" | "/users/[id]" | null type LayoutRouteId = RouteId | "/" | "/[site]" | "/login" | "/logout" | "/notifications" | "/profile" | "/training" | "/training/[id]" | "/training/admin" | "/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<{}>;
+3 -1
View File
@@ -46,10 +46,12 @@ export const load: PageServerLoad = async ({ locals }) => {
}); });
const canUpload = Boolean(user?.training_admin); const canUpload = Boolean(user?.training_admin);
const isAdmin = Boolean((user as any)?.Admin);
return { return {
videos, videos,
canUpload canUpload,
isAdmin
}; };
}; };
+7 -1
View File
@@ -20,11 +20,12 @@
}; };
} | null; } | null;
let { data, form }: { data: { videos: TrainingVideo[]; canUpload: boolean }; form: UploadFormState } = let { data, form }: { data: { videos: TrainingVideo[]; canUpload: boolean; isAdmin: boolean }; form: UploadFormState } =
$props(); $props();
const videos = $derived(data.videos); const videos = $derived(data.videos);
const canUpload = $derived(data.canUpload); const canUpload = $derived(data.canUpload);
const isAdmin = $derived(data.isAdmin);
function formatDate(created: string) { function formatDate(created: string) {
if (!created) return ''; if (!created) return '';
@@ -87,6 +88,11 @@
<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>
{#if isAdmin}
<a href="/training/admin">
<Button variant="outline" size="sm">Admin</Button>
</a>
{/if}
</div> </div>
{#if canUpload} {#if canUpload}
+98
View File
@@ -0,0 +1,98 @@
import { error, 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 = '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;
if (!user) {
throw redirect(303, '/login');
}
if (!(user as any)?.Admin) {
throw error(403, 'You do not have access to training admin.');
}
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) => {
let videoUrl = (record[VIDEO_LINK_FIELD] as string) ?? '';
if (!videoUrl && record[VIDEO_FILE_FIELD]) {
videoUrl = locals.pb.files.getUrl(record, record[VIDEO_FILE_FIELD]);
}
return {
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 {
videos
};
};
export const actions: Actions = {
delete: async ({ request, locals }) => {
const user = locals.user;
if (!user) {
throw redirect(303, '/login');
}
if (!(user as any)?.Admin) {
return fail(403, {
error: 'You do not have permission to manage training videos.'
});
}
const formData = await request.formData();
const id = String(formData.get('id') ?? '').trim();
if (!id) {
return fail(400, {
error: 'Missing video id.'
});
}
try {
await locals.pb.collection(VIDEO_COLLECTION).delete(id);
} catch (e) {
const message =
e && typeof e === 'object' && 'message' in e
? String((e as { message: string }).message)
: 'Could not delete video.';
return fail(500, {
error: message
});
}
throw redirect(303, '/training/admin');
}
};
+100
View File
@@ -0,0 +1,100 @@
<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;
};
type AdminFormState = {
error?: string;
} | null;
let { data, form }: { data: { videos: TrainingVideo[] }; form: AdminFormState } = $props();
const videos = $derived(data.videos);
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 admin — 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-5xl flex-col gap-4 sm:gap-6">
<div class="flex items-center justify-between gap-3">
<a href="/training">
<Button variant="ghost" size="sm">← Training</Button>
</a>
<h1 class="text-xl font-semibold">Training admin</h1>
</div>
<Card.Root>
<Card.Header class="pb-2">
<Card.Title>Videos</Card.Title>
<Card.Description>Manage training videos in the Media collection.</Card.Description>
</Card.Header>
<Card.Content class="space-y-3">
{#if form?.error}
<p class="text-destructive text-sm">{form.error}</p>
{/if}
{#if videos.length === 0}
<p class="text-muted-foreground text-sm">No videos found.</p>
{:else}
<div class="space-y-3">
{#each videos as video (video.id)}
<div class="flex items-start justify-between gap-3 rounded-md border bg-card/40 p-3">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2">
<p class="font-medium truncate">{video.title}</p>
<span class="text-muted-foreground text-xs">{formatDate(video.created)}</span>
</div>
{#if video.description}
<p class="text-muted-foreground text-xs line-clamp-2">
{video.description}
</p>
{/if}
{#if video.videoUrl}
<p class="text-muted-foreground text-xs truncate">
{video.videoUrl}
</p>
{/if}
</div>
<form method="POST" class="flex flex-col items-end gap-2">
<input type="hidden" name="id" value={video.id} />
<Button
type="submit"
name="intent"
value="delete"
variant="destructive"
size="sm"
>
Delete
</Button>
<a href={`/training/${video.id}`}>
<Button variant="outline" size="sm">Open</Button>
</a>
</form>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
</main>