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
173 lines
4.2 KiB
Svelte
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>
|