Grundgerüst: SvelteKit 5 + PocketBase + VitePWA

- Docker Compose Setup (PocketBase + SvelteKit Node)
- Auth: Login, Registrierung (Verein + User in PocketBase)
- Geschützte App-Shell mit Bottom-Navigation (Mobile-first)
- Platzhalterseiten: Mitglieder, Termine, Beiträge, Nachrichten
- TypeScript-Typen für alle Collections
- PWA-Manifest für vereins.haus
- Makefile für SSH-Deploy auf Synology DS
This commit is contained in:
rene 2026-05-18 18:46:33 +02:00
commit 773046c80d
26 changed files with 7779 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.env
.env.*
*.local
.DS_Store

122
Makefile Normal file
View file

@ -0,0 +1,122 @@
# ==============================================================
# VEREINS.HAUS — Makefile
# Deploy-Strategie: SSH zur DS, Docker Compose
# ==============================================================
DS_HOST := ds
DS_IP := 10.47.11.10
DS_SSH_PORT := 4711
DS_PATH := /volume1/docker/vereinshaus
CONTAINER_PB := vereinshaus-pocketbase
CONTAINER_APP := vereinshaus-app
DOCKER := sudo /usr/local/bin/docker
TAR_EXCLUDE := --exclude='.git' \
--exclude='./app/node_modules' \
--exclude='./app/.svelte-kit' \
--exclude='./app/build' \
--exclude='./.env' \
--exclude='./.DS_Store'
HOOKS_SRC := pocketbase/pb_hooks
HOOKS_DST := /volume1/docker/vereinshaus/pocketbase/data/pb_hooks
.PHONY: help check-ssh start stop restart status logs logs-f logs-app \
shell-pb pb-admin deploy
# ----------------------------------------------------------
# Hilfe
# ----------------------------------------------------------
help:
@echo ""
@echo " vereins.haus — verfügbare Befehle:"
@echo ""
@echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make start Alle Container starten"
@echo " make stop Alle Container stoppen"
@echo " make restart Alle Container neu starten"
@echo " make status Container-Status anzeigen"
@echo ""
@echo " make logs PocketBase-Logs (100 Zeilen)"
@echo " make logs-app App-Logs (100 Zeilen)"
@echo " make logs-f PocketBase Live-Log"
@echo " make shell-pb Shell in PocketBase-Container"
@echo " make pb-admin PocketBase Admin-URL anzeigen"
@echo ""
# ----------------------------------------------------------
# SSH-Prüfung
# ----------------------------------------------------------
check-ssh:
@if ! nc -z -w3 $(DS_IP) $(DS_SSH_PORT) 2>/dev/null; then \
echo ""; \
echo " ✗ DS nicht erreichbar ($(DS_IP):$(DS_SSH_PORT))"; \
echo ""; \
exit 1; \
fi
# ----------------------------------------------------------
# DEPLOY
# ----------------------------------------------------------
deploy: check-ssh
@echo "→ Sync zu DS..."
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/"
@echo "→ .env auf DS aktualisieren..."
@if [ -f .env ]; then \
grep -E "BREVO_KEY" .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi
@echo "→ PocketBase Hooks synchronisieren..."
@if ls $(HOOKS_SRC)/*.pb.js 2>/dev/null | grep -q .; then \
for f in $(HOOKS_SRC)/*.pb.js; do \
cat "$$f" | ssh $(DS_HOST) "cat > $(HOOKS_DST)/$$(basename $$f)"; \
done; \
fi
@echo "→ Docker rebuild + restart..."
@ssh $(DS_HOST) " \
cd $(DS_PATH) && \
$(DOCKER) compose down && \
$(DOCKER) compose build app && \
$(DOCKER) compose up -d"
@echo " ✓ Deploy fertig."
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10"
# ----------------------------------------------------------
# Container-Steuerung
# ----------------------------------------------------------
start: check-ssh
@ssh $(DS_HOST) "cd $(DS_PATH) && $(DOCKER) compose up -d"
@echo " ✓ Gestartet."
stop: check-ssh
@ssh $(DS_HOST) "cd $(DS_PATH) && $(DOCKER) compose down"
@echo " ✓ Gestoppt."
restart: check-ssh
@ssh $(DS_HOST) "cd $(DS_PATH) && $(DOCKER) compose restart"
@echo " ✓ Neugestartet."
status: check-ssh
@ssh $(DS_HOST) "$(DOCKER) ps \
--filter name=vereinshaus \
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
# ----------------------------------------------------------
# Logs
# ----------------------------------------------------------
logs: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) --tail=100"
logs-app: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=100"
logs-f: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f"
# ----------------------------------------------------------
# Shell + Admin
# ----------------------------------------------------------
shell-pb: check-ssh
@ssh -t $(DS_HOST) "$(DOCKER) exec -it $(CONTAINER_PB) sh"
pb-admin:
@echo " PocketBase Admin: https://api.vereins.haus/_/"

21
app/.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
node_modules
# Output
.output
.vercel
.netlify
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
app/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

15
app/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
ENV HOST=0.0.0.0 PORT=3000
CMD ["node", "build"]

6622
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
app/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "vereinshaus",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7",
"vite-plugin-pwa": "^1.3.0"
},
"dependencies": {
"pocketbase": "^0.26.9"
}
}

19
app/src/app.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#1e40af" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

13
app/src/lib/pb.ts Normal file
View file

@ -0,0 +1,13 @@
import PocketBase from 'pocketbase';
import { browser } from '$app/environment';
const PB_URL = import.meta.env.VITE_PB_URL ?? 'http://localhost:8090';
export const pb = new PocketBase(PB_URL);
if (browser) {
pb.authStore.loadFromCookie(document.cookie);
pb.authStore.onChange(() => {
document.cookie = pb.authStore.exportToCookie({ httpOnly: false });
});
}

84
app/src/lib/types.ts Normal file
View file

@ -0,0 +1,84 @@
export type Plan = 'free' | 'starter' | 'wachstum' | 'verband';
export type MitgliedStatus = 'aktiv' | 'passiv' | 'ausgetreten';
export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen';
export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
export interface Verein {
id: string;
name: string;
adresse: string;
plz: string;
ort: string;
bundesland: string;
plan: Plan;
stripe_customer_id?: string;
dosb_mitglied: boolean;
}
export interface Mitglied {
id: string;
verein_id: string;
vorname: string;
nachname: string;
email: string;
telefon?: string;
geburtsdatum?: string;
eintrittsdatum: string;
austrittsdatum?: string;
strasse: string;
plz: string;
ort: string;
iban?: string;
bic?: string;
gruppe_ids: string[];
status: MitgliedStatus;
notizen?: string;
}
export interface Gruppe {
id: string;
verein_id: string;
name: string;
beschreibung?: string;
}
export interface Beitrag {
id: string;
verein_id: string;
name: string;
betrag: number;
rhythmus: Rhythmus;
beschreibung?: string;
}
export interface Einzug {
id: string;
verein_id: string;
mitglied_id: string;
beitrag_id: string;
betrag: number;
faelligkeitsdatum: string;
status: EinzugStatus;
stripe_payment_intent_id?: string;
}
export interface Termin {
id: string;
verein_id: string;
titel: string;
beschreibung?: string;
beginn: string;
ende?: string;
ort?: string;
gruppe_ids: string[];
}
export interface Nachricht {
id: string;
verein_id: string;
autor_id: string;
betreff: string;
text: string;
gruppe_ids: string[];
gesendet_am: string;
}

View file

@ -0,0 +1,138 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
let { children } = $props();
onMount(() => {
if (!pb.authStore.isValid) {
goto('/login');
}
});
function logout() {
pb.authStore.clear();
goto('/login');
}
const navItems = [
{ href: '/', label: 'Übersicht', icon: '⊞' },
{ href: '/mitglieder', label: 'Mitglieder', icon: '👥' },
{ href: '/termine', label: 'Termine', icon: '📅' },
{ href: '/beitraege', label: 'Beiträge', icon: '💶' },
{ href: '/nachrichten', label: 'Nachrichten', icon: '✉️' },
];
</script>
<div class="shell">
<header>
<span class="logo">vereins.haus</span>
<button class="logout-btn" onclick={logout}>Abmelden</button>
</header>
<main>
{@render children()}
</main>
<nav class="bottom-nav">
{#each navItems as item}
<a
href={item.href}
class:active={$page.url.pathname === item.href}
>
<span class="nav-icon">{item.icon}</span>
<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 {
font-size: 1.1rem;
font-weight: 700;
color: #1e40af;
letter-spacing: -0.02em;
}
.logout-btn {
background: none;
border: none;
font-size: 0.85rem;
color: #64748b;
padding: 0.25rem 0.5rem;
}
.logout-btn: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-icon {
font-size: 1.3rem;
line-height: 1;
}
.nav-label {
font-size: 0.65rem;
font-weight: 500;
}
</style>

View file

@ -0,0 +1 @@
export const ssr = false;

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { pb } from '$lib/pb';
const vereinsname = pb.authStore.record?.name ?? 'Dein Verein';
</script>
<svelte:head>
<title>Übersicht — vereins.haus</title>
</svelte:head>
<h1>Willkommen, {vereinsname}</h1>
<div class="cards">
<a href="/mitglieder" class="card">
<span class="card-icon">👥</span>
<span class="card-label">Mitglieder</span>
</a>
<a href="/termine" class="card">
<span class="card-icon">📅</span>
<span class="card-label">Termine</span>
</a>
<a href="/beitraege" class="card">
<span class="card-icon">💶</span>
<span class="card-label">Beiträge</span>
</a>
<a href="/nachrichten" class="card">
<span class="card-icon">✉️</span>
<span class="card-label">Nachrichten</span>
</a>
</div>
<style>
h1 {
font-size: 1.4rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 1.5rem;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.card {
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: border-color .15s, box-shadow .15s;
}
.card:hover {
border-color: #1e40af;
box-shadow: 0 2px 8px rgba(30,64,175,.1);
}
.card-icon {
font-size: 2rem;
}
.card-label {
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
}
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
</script>
<svelte:head><title>Beiträge — vereins.haus</title></svelte:head>
<div class="page-header">
<h1>Beiträge</h1>
<button class="btn-primary">+ Beitragsart</button>
</div>
<p class="placeholder">SEPA-Beitragseinzug — in Entwicklung</p>
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.btn-primary {
background: #1e40af; color: #fff; border: none;
border-radius: 8px; padding: 0.5rem 1rem;
font-size: 0.9rem; font-weight: 600;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
</script>
<svelte:head><title>Mitglieder — vereins.haus</title></svelte:head>
<div class="page-header">
<h1>Mitglieder</h1>
<button class="btn-primary">+ Mitglied</button>
</div>
<p class="placeholder">Mitgliederverwaltung — in Entwicklung</p>
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.btn-primary {
background: #1e40af; color: #fff; border: none;
border-radius: 8px; padding: 0.5rem 1rem;
font-size: 0.9rem; font-weight: 600;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
</script>
<svelte:head><title>Nachrichten — vereins.haus</title></svelte:head>
<div class="page-header">
<h1>Nachrichten</h1>
<button class="btn-primary">+ Nachricht</button>
</div>
<p class="placeholder">Nachrichten & Push-Benachrichtigungen — in Entwicklung</p>
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.btn-primary {
background: #1e40af; color: #fff; border: none;
border-radius: 8px; padding: 0.5rem 1rem;
font-size: 0.9rem; font-weight: 600;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
</script>
<svelte:head><title>Termine — vereins.haus</title></svelte:head>
<div class="page-header">
<h1>Termine</h1>
<button class="btn-primary">+ Termin</button>
</div>
<p class="placeholder">Terminkalender — in Entwicklung</p>
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.btn-primary {
background: #1e40af; color: #fff; border: none;
border-radius: 8px; padding: 0.5rem 1rem;
font-size: 0.9rem; font-weight: 600;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
let { children } = $props();
onMount(() => {
if (pb.authStore.isValid) {
goto('/');
}
});
</script>
<div class="auth-wrap">
<div class="auth-card">
<a href="/" class="logo">vereins.haus</a>
{@render children()}
</div>
</div>
<style>
.auth-wrap {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: #f8fafc;
}
.auth-card {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.06);
}
.logo {
display: block;
font-size: 1.4rem;
font-weight: 700;
color: #1e40af;
margin-bottom: 1.5rem;
letter-spacing: -0.02em;
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
let email = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function login() {
error = '';
loading = true;
try {
await pb.collection('users').authWithPassword(email, password);
goto('/');
} catch {
error = 'E-Mail oder Passwort falsch.';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Anmelden — vereins.haus</title>
</svelte:head>
<h1>Anmelden</h1>
<form onsubmit={(e) => { e.preventDefault(); login(); }}>
<div class="field">
<label for="email">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
autocomplete="email"
required
/>
</div>
<div class="field">
<label for="password">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
autocomplete="current-password"
required
/>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<button type="submit" disabled={loading}>
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
<p class="switch">
Noch kein Konto? <a href="/register">Verein registrieren</a>
</p>
<style>
h1 {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
label {
font-size: 0.9rem;
font-weight: 500;
color: #475569;
}
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color .15s;
background: #fff;
}
input:focus {
outline: none;
border-color: #1e40af;
}
.error {
color: #dc2626;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
button {
width: 100%;
padding: 0.75rem;
background: #1e40af;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
margin-top: 0.5rem;
transition: background .15s;
}
button:hover:not(:disabled) {
background: #1d3a9e;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.switch {
margin-top: 1.25rem;
font-size: 0.9rem;
color: #64748b;
text-align: center;
}
.switch a {
color: #1e40af;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
let vereinsname = $state('');
let email = $state('');
let password = $state('');
let passwordConfirm = $state('');
let error = $state('');
let loading = $state(false);
async function register() {
error = '';
if (password !== passwordConfirm) {
error = 'Passwörter stimmen nicht überein.';
return;
}
loading = true;
try {
const user = await pb.collection('users').create({
email,
password,
passwordConfirm,
name: vereinsname
});
await pb.collection('vereine').create({
name: vereinsname,
plan: 'free',
dosb_mitglied: false,
user_id: user.id
});
await pb.collection('users').authWithPassword(email, password);
goto('/');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Verein registrieren — vereins.haus</title>
</svelte:head>
<h1>Verein registrieren</h1>
<p class="subtitle">Kostenlos starten — kein Kreditkarte nötig</p>
<form onsubmit={(e) => { e.preventDefault(); register(); }}>
<div class="field">
<label for="vereinsname">Name des Vereins</label>
<input
id="vereinsname"
type="text"
bind:value={vereinsname}
placeholder="z. B. TSV Musterstadt 1923"
required
/>
</div>
<div class="field">
<label for="email">E-Mail (Admin)</label>
<input
id="email"
type="email"
bind:value={email}
autocomplete="email"
required
/>
</div>
<div class="field">
<label for="password">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
autocomplete="new-password"
minlength="8"
required
/>
</div>
<div class="field">
<label for="passwordConfirm">Passwort bestätigen</label>
<input
id="passwordConfirm"
type="password"
bind:value={passwordConfirm}
autocomplete="new-password"
required
/>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<button type="submit" disabled={loading}>
{loading ? 'Registrieren…' : 'Kostenlos registrieren'}
</button>
</form>
<p class="switch">
Bereits registriert? <a href="/login">Anmelden</a>
</p>
<style>
h1 {
font-size: 1.4rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.25rem;
}
.subtitle {
font-size: 0.9rem;
color: #64748b;
margin-bottom: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
label {
font-size: 0.9rem;
font-weight: 500;
color: #475569;
}
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color .15s;
background: #fff;
}
input:focus {
outline: none;
border-color: #1e40af;
}
.error {
color: #dc2626;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
button {
width: 100%;
padding: 0.75rem;
background: #1e40af;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
margin-top: 0.5rem;
transition: background .15s;
}
button:hover:not(:disabled) {
background: #1d3a9e;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.switch {
margin-top: 1.25rem;
font-size: 0.9rem;
color: #64748b;
text-align: center;
}
.switch a {
color: #1e40af;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,54 @@
<script lang="ts">
let { children } = $props();
</script>
<svelte:head>
<meta charset="utf-8" />
<title>vereins.haus — Vereinsverwaltung die einfach funktioniert</title>
<meta name="description" content="Mobile-first Vereinsverwaltung mit SEPA-Beitragseinzug, Terminkalender und Mitgliederverwaltung. Kostenlos starten." />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://vereins.haus" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="vereins.haus" />
<meta property="og:locale" content="de_DE" />
<meta property="og:title" content="vereins.haus — Vereinsverwaltung die einfach funktioniert" />
<meta property="og:description" content="Mobile-first Vereinsverwaltung mit SEPA-Beitragseinzug, Terminkalender und Mitgliederverwaltung." />
</svelte:head>
{@render children()}
<style>
:global(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(html) {
color-scheme: light;
font-size: 16px;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #1e293b;
background: #f8fafc;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
:global(a) {
color: inherit;
text-decoration: none;
}
:global(button) {
cursor: pointer;
font-family: inherit;
}
:global(input, textarea, select) {
font-family: inherit;
font-size: 1rem;
}
</style>

12
app/svelte.config.js Normal file
View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

13
app/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
}

39
app/vite.config.ts Normal file
View file

@ -0,0 +1,39 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
sveltekit(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'vereins.haus',
short_name: 'vereins.haus',
description: 'Vereinsverwaltung die einfach funktioniert',
theme_color: '#1e40af',
background_color: '#f8fafc',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
{ src: '/icons/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.vereins\.haus\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'pocketbase-api',
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
}
}
]
}
})
]
});

37
docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
version: "3.8"
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: vereinshaus-pocketbase
restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus/pocketbase/data:/pb_data
- /volume1/docker/vereinshaus/pocketbase/storage:/pb_public
- /volume1/docker/vereinshaus/pocketbase/data/pb_hooks:/pb_hooks
environment:
- TZ=Europe/Berlin
- BREVO_KEY=${BREVO_KEY}
networks:
- default
- npm_bridge
app:
build:
context: ./app
dockerfile: Dockerfile
image: vereinshaus-app
container_name: vereinshaus-app
restart: unless-stopped
environment:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
networks:
- default
- npm_bridge
networks:
npm_bridge:
external: true
name: nginx-proxy-manager_bridge_net

View file