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:
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