vereinshaus/app/src/routes/(app)/+layout.svelte
rene 39981c0d17 Migrate: PocketBase → SvelteKit + better-sqlite3 + JWT
Vollständige Migration weg von PocketBase. Neuer Stack:
- better-sqlite3 (WAL-Mode, direkte SQLite-Abfragen)
- jose (JWT HS256, 30 Tage Laufzeit)
- bcryptjs (Passwort-Hashing, cost 12)

Neue Dateien:
- src/lib/server/db.ts    → SQLite-Singleton + Schema + Helpers
- src/lib/server/auth.ts  → JWT sign/verify, bcrypt, Bearer-Token
- src/lib/user.ts         → Svelte-Store (ersetzt pb.authStore)
- src/lib/api.ts          → fetch()-Wrapper (ersetzt pb.collection())
- src/app.d.ts            → App.Locals TypeScript-Deklaration
- 30 neue API-Routes unter src/routes/api/

Entfernt:
- Abhängigkeit von pocketbase npm-Paket (bleibt im package.json bis
  alle Referenzen bereinigt sind)
- PocketBase-Container aus docker-compose.yml
- Migrations und Hooks aus Deploy-Pipeline

Docker: Ein einziger Container, SQLite-Volume unter /data/
Makefile: PocketBase-spezifische Targets entfernt
seed.js: Komplett neu für neue REST-API
2026-05-21 21:55:04 +02:00

173 lines
4.2 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { user } from '$lib/user';
import Icon from '$lib/components/Icon.svelte';
import type { IconName } from '$lib/icons';
let { children } = $props();
onMount(() => {
if (!$user) { goto('/login'); return; }
if (!$user.verein_id) { goto('/onboarding'); return; }
registerPush();
});
async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
try {
const reg = await navigator.serviceWorker.ready;
const keyRes = await fetch('/api/push/key');
const { publicKey } = await keyRes.json();
if (!publicKey) return;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
});
}
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$user?.token}` },
body: JSON.stringify({ subscription: sub.toJSON(), userId: $user?.id }),
});
} catch (e) {
console.warn('[push] Registrierung fehlgeschlagen:', e);
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
const isAdmin = () => !$user?.rolle || $user?.rolle === 'admin';
const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
{ href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' },
{ href: '/mitglieder', label: 'Mitglieder', icon: 'users' },
{ href: '/termine', label: 'Termine', icon: 'calendar' },
{ href: '/beitraege', label: 'Beiträge', icon: 'currency-eur', adminOnly: true },
{ href: '/nachrichten', label: 'Nachrichten', icon: 'envelope' },
];
const navItems = $derived(allNavItems.filter(i => !i.adminOnly || isAdmin()));
</script>
<div class="shell">
<header>
<a href="/" class="logo">
<img src="/favicon.svg" alt="" width="28" height="28" />
vereins.haus
</a>
<a href="/einstellungen" class="header-icon" aria-label="Einstellungen">
<Icon name="gear" size={22} />
</a>
</header>
<main>
{@render children()}
</main>
<nav class="bottom-nav">
{#each navItems as item}
<a
href={item.href}
class:active={$page.url.pathname === item.href}
>
<Icon name={item.icon} size={22} />
<span class="nav-label">{item.label}</span>
</a>
{/each}
</nav>
</div>
<style>
.shell {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.logo {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 1.05rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
text-decoration: none;
}
.header-icon {
color: #64748b;
display: flex;
align-items: center;
padding: 0.25rem;
transition: color 0.15s;
}
.header-icon:hover { color: #1e293b; }
main {
flex: 1;
padding: 1.25rem 1rem;
padding-bottom: calc(70px + env(safe-area-inset-bottom));
max-width: 720px;
width: 100%;
margin: 0 auto;
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(60px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid #e2e8f0;
display: flex;
z-index: 10;
}
.bottom-nav a {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
color: #94a3b8;
transition: color .15s;
font-size: 0.7rem;
text-decoration: none;
}
.bottom-nav a.active {
color: #1e40af;
}
.nav-label {
font-size: 0.65rem;
font-weight: 500;
}
</style>