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:
commit
773046c80d
26 changed files with 7779 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.env
|
||||
.env.*
|
||||
*.local
|
||||
.DS_Store
|
||||
122
Makefile
Normal file
122
Makefile
Normal 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
21
app/.gitignore
vendored
Normal 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
1
app/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
15
app/Dockerfile
Normal file
15
app/Dockerfile
Normal 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
6622
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
app/package.json
Normal file
27
app/package.json
Normal 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
19
app/src/app.html
Normal 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
13
app/src/lib/pb.ts
Normal 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
84
app/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
138
app/src/routes/(app)/+layout.svelte
Normal file
138
app/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
1
app/src/routes/(app)/+layout.ts
Normal file
1
app/src/routes/(app)/+layout.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const ssr = false;
|
||||
72
app/src/routes/(app)/+page.svelte
Normal file
72
app/src/routes/(app)/+page.svelte
Normal 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>
|
||||
27
app/src/routes/(app)/beitraege/+page.svelte
Normal file
27
app/src/routes/(app)/beitraege/+page.svelte
Normal 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>
|
||||
27
app/src/routes/(app)/mitglieder/+page.svelte
Normal file
27
app/src/routes/(app)/mitglieder/+page.svelte
Normal 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>
|
||||
27
app/src/routes/(app)/nachrichten/+page.svelte
Normal file
27
app/src/routes/(app)/nachrichten/+page.svelte
Normal 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>
|
||||
27
app/src/routes/(app)/termine/+page.svelte
Normal file
27
app/src/routes/(app)/termine/+page.svelte
Normal 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>
|
||||
49
app/src/routes/(auth)/+layout.svelte
Normal file
49
app/src/routes/(auth)/+layout.svelte
Normal 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>
|
||||
140
app/src/routes/(auth)/login/+page.svelte
Normal file
140
app/src/routes/(auth)/login/+page.svelte
Normal 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>
|
||||
188
app/src/routes/(auth)/register/+page.svelte
Normal file
188
app/src/routes/(auth)/register/+page.svelte
Normal 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>
|
||||
54
app/src/routes/+layout.svelte
Normal file
54
app/src/routes/+layout.svelte
Normal 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
12
app/svelte.config.js
Normal 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
13
app/tsconfig.json
Normal 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
39
app/vite.config.ts
Normal 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
37
docker-compose.yml
Normal 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
|
||||
0
pocketbase/pb_hooks/.gitkeep
Normal file
0
pocketbase/pb_hooks/.gitkeep
Normal file
Loading…
Add table
Add a link
Reference in a new issue