diff --git a/Makefile b/Makefile index 5a6b184..9711355 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,19 @@ # ============================================================== # VEREINS.HAUS — Makefile # Deploy-Strategie: SSH zur DS, Docker Compose +# Stack: SvelteKit + better-sqlite3 (kein PocketBase) # ============================================================== 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 +STAGING_PATH := /volume1/docker/vereinshaus-staging +CONTAINER_APP_STAGING := vereinshaus-staging-app + TAR_EXCLUDE := --exclude='.git' \ --exclude='./app/node_modules' \ --exclude='./app/.svelte-kit' \ @@ -18,11 +21,8 @@ TAR_EXCLUDE := --exclude='.git' \ --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 +.PHONY: help check-ssh start stop restart status logs logs-app logs-f deploy \ + staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop # ---------------------------------------------------------- # Hilfe @@ -31,17 +31,17 @@ 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 " make deploy App bauen + zur DS übertragen + Container neu starten" + @echo " make start Container starten" + @echo " make stop Container stoppen" + @echo " make restart Container neu starten" + @echo " make status Container-Status anzeigen" + @echo " make logs App-Logs (100 Zeilen)" + @echo " make logs-f App Live-Log" @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 " make staging-deploy Staging deployen" + @echo " make staging-seed Testdaten einfügen" + @echo " make staging-reset Staging-DB löschen (Neustart)" @echo "" # ---------------------------------------------------------- @@ -56,20 +56,14 @@ check-ssh: fi # ---------------------------------------------------------- -# DEPLOY +# DEPLOY (Production) # ---------------------------------------------------------- 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; \ + cat .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \ fi @echo "→ Docker rebuild + restart..." @ssh $(DS_HOST) " \ @@ -78,7 +72,7 @@ deploy: check-ssh $(DOCKER) compose build app && \ $(DOCKER) compose up -d" @echo " ✓ Deploy fertig." - @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10" + @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=15" # ---------------------------------------------------------- # Container-Steuerung @@ -104,19 +98,61 @@ status: check-ssh # 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-app: logs + logs-f: check-ssh - @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f" + @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) -f" -# ---------------------------------------------------------- -# Shell + Admin -# ---------------------------------------------------------- -shell-pb: check-ssh - @ssh -t $(DS_HOST) "$(DOCKER) exec -it $(CONTAINER_PB) sh" +# ============================================================== +# STAGING +# ============================================================== -pb-admin: - @echo " PocketBase Admin: https://api.vereins.haus/_/" +staging-deploy: check-ssh + @echo "→ Sync zu DS (Staging)..." + @COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(STAGING_PATH)/" + @echo "→ .env auf DS (Staging)..." + @if [ -f .env ]; then \ + cat .env | ssh $(DS_HOST) "cat > $(STAGING_PATH)/.env"; \ + fi + @echo "→ Docker rebuild + restart (Staging)..." + @ssh $(DS_HOST) " \ + cd $(STAGING_PATH) && \ + $(DOCKER) compose -f docker-compose.staging.yml down && \ + $(DOCKER) compose -f docker-compose.staging.yml build app-staging && \ + $(DOCKER) compose -f docker-compose.staging.yml up -d" + @echo " ✓ Staging bereit." + @echo " App: https://staging.vereins.haus" + +# Löscht die SQLite-DB auf Staging → frischer Start +# Danach: make staging-deploy && make staging-seed +staging-reset: check-ssh staging-stop + @echo "→ Staging-Daten löschen..." + @ssh $(DS_HOST) "rm -f \ + $(STAGING_PATH)/data/vereinshaus.db \ + $(STAGING_PATH)/data/vereinshaus.db-wal \ + $(STAGING_PATH)/data/vereinshaus.db-shm && \ + rm -rf $(STAGING_PATH)/data/uploads" + @echo " ✓ Reset fertig. Jetzt: make staging-deploy && make staging-seed" + +staging-seed: + @echo "→ Testdaten in Staging einfügen..." + @if [ -f .env ]; then \ + export $$(grep -v '^#' .env | xargs) && \ + APP_URL=https://staging.vereins.haus node scripts/seed.js; \ + else \ + APP_URL=https://staging.vereins.haus node scripts/seed.js; \ + fi + +staging-logs: check-ssh + @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP_STAGING) --tail=50" + +staging-status: check-ssh + @ssh $(DS_HOST) "$(DOCKER) ps \ + --filter name=vereinshaus-staging \ + --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" + +staging-stop: check-ssh + @ssh $(DS_HOST) "cd $(STAGING_PATH) && $(DOCKER) compose -f docker-compose.staging.yml down" + @echo " ✓ Staging gestoppt." diff --git a/app/Dockerfile b/app/Dockerfile index 7831668..bd6f749 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,4 +1,5 @@ FROM node:22-alpine AS builder +RUN apk add --no-cache python3 make g++ WORKDIR /app COPY package*.json ./ RUN npm ci @@ -6,6 +7,7 @@ COPY . . RUN npm run build FROM node:22-alpine +RUN apk add --no-cache python3 make g++ WORKDIR /app COPY --from=builder /app/build ./build COPY --from=builder /app/package*.json ./ diff --git a/app/package-lock.json b/app/package-lock.json index 7d728af..f27e45c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,17 +8,31 @@ "name": "vereinshaus", "version": "0.1.0", "dependencies": { - "pocketbase": "^0.26.9" + "@event-calendar/core": "^5.7.0", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.10.0", + "ical-generator": "^10.2.0", + "jose": "^6.2.3", + "papaparse": "^5.5.3", + "pocketbase": "^0.26.9", + "rrule": "^2.8.1", + "web-push": "^3.6.7" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.13", + "@types/papaparse": "^5.5.2", + "@types/web-push": "^3.6.4", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", "vite": "^8.0.7", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "workbox-core": "^7.4.1", + "workbox-precaching": "^7.4.1" } }, "node_modules/@apideck/better-ajv-errors": { @@ -1614,6 +1628,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@event-calendar/core": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@event-calendar/core/-/core-5.7.0.tgz", + "integrity": "sha512-16S9TncV/az52qFjvmB691fMQA8qIo/Iz4kxrvOMLwD46oFzin07aWQQPBcgCFUN+58m9AbFYWnxkvO7OYAldg==", + "license": "MIT", + "dependencies": { + "svelte": "^5.55.4" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -1628,7 +1651,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1639,7 +1661,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1650,7 +1671,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1671,14 +1691,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2514,7 +2532,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -2625,6 +2642,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2636,9 +2670,28 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2650,14 +2703,22 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2666,6 +2727,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2687,7 +2757,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2732,6 +2801,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2779,7 +2860,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2837,6 +2917,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.31", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", @@ -2850,6 +2950,55 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -2897,6 +3046,36 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2991,11 +3170,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3139,7 +3323,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3153,6 +3336,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3203,7 +3410,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -3213,7 +3419,6 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, "license": "MIT" }, "node_modules/dunder-proto": { @@ -3231,6 +3436,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3254,6 +3468,15 @@ "dev": true, "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3404,14 +3627,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.9", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -3455,6 +3676,15 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3504,6 +3734,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -3577,6 +3813,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -3733,6 +3975,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -3879,6 +4127,76 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ical-generator": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-10.2.0.tgz", + "integrity": "sha512-XR5FsiDWCsz5MwBwMA/sQqR3A9H240xkXIeXOabV7uNAiieP+TA9rleVvlwPLRXMz+CXME8cGuDd7cdnE5At6w==", + "license": "MIT", + "engines": { + "node": "20 || 22 || >=24" + }, + "peerDependencies": { + "@touch4it/ical-timezones": ">=1.6.0", + "@types/luxon": ">= 1.26.0", + "@types/mocha": ">= 8.2.1", + "dayjs": ">= 1.10.0", + "luxon": ">= 1.26.0", + "moment": ">= 2.29.0", + "moment-timezone": ">= 0.5.33", + "rrule": ">= 2.6.8" + }, + "peerDependenciesMeta": { + "@touch4it/ical-timezones": { + "optional": true + }, + "@types/luxon": { + "optional": true + }, + "@types/mocha": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-timezone": { + "optional": true + }, + "rrule": { + "optional": true + } + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -3886,6 +4204,38 @@ "dev": true, "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4358,6 +4708,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4421,6 +4780,27 @@ "node": ">=0.10.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -4706,7 +5086,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -4737,7 +5116,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -4753,6 +5131,24 @@ "node": ">= 0.4" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -4769,6 +5165,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -4779,6 +5184,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -4803,7 +5214,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4825,6 +5235,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.44", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", @@ -4887,6 +5327,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -4912,6 +5361,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5021,6 +5476,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -5034,6 +5516,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5044,6 +5536,35 @@ "node": ">=6" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5278,6 +5799,15 @@ "dev": true, "license": "MIT" }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -5311,6 +5841,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5346,6 +5896,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5534,6 +6090,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -5618,6 +6219,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -5730,6 +6340,15 @@ "node": ">=10" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -5747,7 +6366,6 @@ "version": "5.55.8", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.8.tgz", "integrity": "sha512-4D6lyrMHmDaZalQOEBMCWCCidyZjSnec14/oPn0k627G6goxcck9xqMwz1tFLlQz+ZFvtTTHfFOlUayuAz0z6Q==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -5799,12 +6417,39 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -5894,9 +6539,19 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } }, "node_modules/type-fest": { "version": "0.16.0", @@ -6022,6 +6677,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -6131,6 +6793,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", @@ -6260,6 +6928,25 @@ } } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -6604,6 +7291,12 @@ "workbox-core": "7.4.1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6615,7 +7308,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/app/package.json b/app/package.json index e0fc5a5..9f4431d 100644 --- a/app/package.json +++ b/app/package.json @@ -15,13 +15,27 @@ "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.13", + "@types/papaparse": "^5.5.2", + "@types/web-push": "^3.6.4", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", "vite": "^8.0.7", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "workbox-core": "^7.4.1", + "workbox-precaching": "^7.4.1" }, "dependencies": { - "pocketbase": "^0.26.9" + "@event-calendar/core": "^5.7.0", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.10.0", + "ical-generator": "^10.2.0", + "jose": "^6.2.3", + "papaparse": "^5.5.3", + "pocketbase": "^0.26.9", + "rrule": "^2.8.1", + "web-push": "^3.6.7" } } diff --git a/app/src/app.d.ts b/app/src/app.d.ts new file mode 100644 index 0000000..d437a87 --- /dev/null +++ b/app/src/app.d.ts @@ -0,0 +1,15 @@ +declare global { + namespace App { + interface Locals { + user: { + id: string; + verein_id: string; + rolle: string | null; + name: string; + email: string; + } | null; + } + } +} + +export {}; diff --git a/app/src/app.html b/app/src/app.html index 6aac5ef..7842f62 100644 --- a/app/src/app.html +++ b/app/src/app.html @@ -4,13 +4,12 @@ - + - + - - + %sveltekit.head% diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts new file mode 100644 index 0000000..5db04ec --- /dev/null +++ b/app/src/lib/api.ts @@ -0,0 +1,66 @@ +import { get } from 'svelte/store'; +import { user } from './user'; +import { goto } from '$app/navigation'; + +function token() { return get(user)?.token ?? ''; } + +function headers(extra: Record = {}): Record { + return { Authorization: `Bearer ${token()}`, ...extra }; +} + +async function handleRes(res: Response): Promise { + if (res.status === 401) { user.clear(); goto('/login'); throw new Error('Nicht angemeldet'); } + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error((e as { message?: string }).message || res.statusText); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +export const api = { + async get(path: string, query: Record = {}): Promise { + const url = new URL('/api' + path, location.origin); + Object.entries(query).forEach(([k, v]) => v !== undefined && url.searchParams.set(k, v)); + return handleRes(await fetch(url.toString(), { headers: headers() })); + }, + + async post(path: string, data?: unknown): Promise { + return handleRes(await fetch('/api' + path, { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(data ?? {}), + })); + }, + + async put(path: string, data?: unknown): Promise { + return handleRes(await fetch('/api' + path, { + method: 'PUT', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(data ?? {}), + })); + }, + + async patch(path: string, data?: unknown): Promise { + return handleRes(await fetch('/api' + path, { + method: 'PATCH', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(data ?? {}), + })); + }, + + async del(path: string): Promise { + return handleRes(await fetch('/api' + path, { method: 'DELETE', headers: headers() })); + }, + + async postForm(path: string, form: FormData): Promise { + return handleRes(await fetch('/api' + path, { + method: 'POST', headers: headers(), body: form, + })); + }, + + fileUrl(verein_id: string, record_id: string, filename: string, thumb = false): string { + const base = `/api/files/${verein_id}/${record_id}/${filename}`; + return thumb ? base + '?thumb=1' : base; + } +}; diff --git a/app/src/lib/components/Icon.svelte b/app/src/lib/components/Icon.svelte new file mode 100644 index 0000000..3af0fc2 --- /dev/null +++ b/app/src/lib/components/Icon.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/lib/event-calendar.d.ts b/app/src/lib/event-calendar.d.ts new file mode 100644 index 0000000..703104d --- /dev/null +++ b/app/src/lib/event-calendar.d.ts @@ -0,0 +1,13 @@ +declare module '@event-calendar/core' { + import type { SvelteComponent } from 'svelte'; + + export class Calendar extends SvelteComponent<{ + plugins: any[]; + options: Record; + }> {} + + export const TimeGrid: any; + export const DayGrid: any; + export const List: any; + export const Interaction: any; +} diff --git a/app/src/lib/icons.ts b/app/src/lib/icons.ts new file mode 100644 index 0000000..bf74341 --- /dev/null +++ b/app/src/lib/icons.ts @@ -0,0 +1,19 @@ +import house from './icons/house.svg?raw'; +import users from './icons/users.svg?raw'; +import calendar from './icons/calendar.svg?raw'; +import currencyEur from './icons/currency-eur.svg?raw'; +import envelope from './icons/envelope.svg?raw'; +import gear from './icons/gear.svg?raw'; +import images from './icons/images.svg?raw'; + +export const icons = { + house, + users, + calendar, + 'currency-eur': currencyEur, + envelope, + gear, + images, +} as const; + +export type IconName = keyof typeof icons; diff --git a/app/src/lib/icons/calendar.svg b/app/src/lib/icons/calendar.svg new file mode 100644 index 0000000..3b91cc4 --- /dev/null +++ b/app/src/lib/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/currency-eur.svg b/app/src/lib/icons/currency-eur.svg new file mode 100644 index 0000000..c23e136 --- /dev/null +++ b/app/src/lib/icons/currency-eur.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/envelope.svg b/app/src/lib/icons/envelope.svg new file mode 100644 index 0000000..f07a98b --- /dev/null +++ b/app/src/lib/icons/envelope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/gear.svg b/app/src/lib/icons/gear.svg new file mode 100644 index 0000000..9a20171 --- /dev/null +++ b/app/src/lib/icons/gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/house.svg b/app/src/lib/icons/house.svg new file mode 100644 index 0000000..6346350 --- /dev/null +++ b/app/src/lib/icons/house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/images.svg b/app/src/lib/icons/images.svg new file mode 100644 index 0000000..48ffc9a --- /dev/null +++ b/app/src/lib/icons/images.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/users.svg b/app/src/lib/icons/users.svg new file mode 100644 index 0000000..7f7b6ca --- /dev/null +++ b/app/src/lib/icons/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/sepa.ts b/app/src/lib/sepa.ts new file mode 100644 index 0000000..a49ff21 --- /dev/null +++ b/app/src/lib/sepa.ts @@ -0,0 +1,109 @@ +export interface SepaKopf { + glaeubigerid: string; + vereinIban: string; + vereinBic: string; + vereinName: string; + einzugsdatum: string; // YYYY-MM-DD +} + +export interface SepaPosition { + endToEndId: string; + betrag: number; + mandatsreferenz: string; + mandatsdatum: string; // YYYY-MM-DD + debitorName: string; + debitorIban: string; + debitorBic: string; + verwendungszweck: string; +} + +function x(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function iban(raw: string): string { + return raw.replace(/\s/g, '').toUpperCase(); +} + +export function generatePain008(kopf: SepaKopf, positionen: SepaPosition[]): string { + if (positionen.length === 0) throw new Error('Keine Positionen für den SEPA-Export.'); + + const now = new Date().toISOString().slice(0, 19); + const msgId = `VH-${Date.now()}`; + const ctrlSum = positionen.reduce((s, p) => s + p.betrag, 0).toFixed(2); + const nbOfTxs = positionen.length; + + const txXml = positionen.map((p) => ` + + ${x(p.endToEndId)} + ${p.betrag.toFixed(2)} + + + ${x(p.mandatsreferenz)} + ${p.mandatsdatum} + + + ${x(kopf.glaeubigerid)} + SEPA + + + ${p.debitorBic ? `${x(p.debitorBic)}` : 'NOTPROVIDED'} + ${x(p.debitorName)} + ${iban(p.debitorIban)} + ${x(p.verwendungszweck.slice(0, 140))} + `).join(''); + + return ` + + + + ${msgId} + ${now} + ${nbOfTxs} + ${ctrlSum} + ${x(kopf.vereinName)} + + + ${msgId}-PI + DD + ${nbOfTxs} + ${ctrlSum} + + SEPA + CORE + RCUR + + ${kopf.einzugsdatum} + ${x(kopf.vereinName)} + ${iban(kopf.vereinIban)} + ${x(kopf.vereinBic)} + + ${x(kopf.glaeubigerid)} + SEPA + ${txXml} + + +`; +} + +export function downloadXml(xml: string, dateiname: string) { + const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = dateiname; + a.click(); + URL.revokeObjectURL(url); +} + +export function minEinzugsdatum(): string { + // SEPA CORE RCUR: mindestens 2 Bankarbeitstage Vorlaufzeit (vereinfacht: +3 Tage) + const d = new Date(); + d.setDate(d.getDate() + 3); + return d.toISOString().slice(0, 10); +} diff --git a/app/src/lib/server/auth.ts b/app/src/lib/server/auth.ts new file mode 100644 index 0000000..1bae1ff --- /dev/null +++ b/app/src/lib/server/auth.ts @@ -0,0 +1,52 @@ +import { SignJWT, jwtVerify } from 'jose'; +import bcrypt from 'bcryptjs'; +import { error } from '@sveltejs/kit'; + +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || 'vereinshaus-dev-secret-change-in-production' +); + +export interface JwtPayload { + sub: string; + verein_id: string; + rolle: string | null; + name: string; + email: string; +} + +export async function signJwt(payload: JwtPayload): Promise { + return new SignJWT({ ...payload }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('30d') + .sign(JWT_SECRET); +} + +export async function verifyJwt(token: string): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + return payload as unknown as JwtPayload; + } catch { + return null; + } +} + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 12); +} + +export async function checkPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +export function bearerToken(request: Request): string | null { + const h = request.headers.get('Authorization'); + return h?.startsWith('Bearer ') ? h.slice(7) : null; +} + +export async function requireAuth(request: Request): Promise { + const token = bearerToken(request); + if (!token) throw error(401, 'Nicht authentifiziert'); + const user = await verifyJwt(token); + if (!user) throw error(401, 'Ungültiger Token'); + return user; +} diff --git a/app/src/lib/server/db.ts b/app/src/lib/server/db.ts new file mode 100644 index 0000000..c7919f7 --- /dev/null +++ b/app/src/lib/server/db.ts @@ -0,0 +1,219 @@ +import Database from 'better-sqlite3'; +import { mkdirSync, existsSync } from 'fs'; +import { dirname } from 'path'; + +const DB_PATH = process.env.DB_PATH || './data/vereinshaus.db'; + +const SCHEMA = ` +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA busy_timeout = 5000; + +CREATE TABLE IF NOT EXISTS vereine ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + name TEXT NOT NULL, + adresse TEXT, plz TEXT, ort TEXT, bundesland TEXT, + plan TEXT NOT NULL DEFAULT 'free', + dosb_mitglied INTEGER NOT NULL DEFAULT 0, + email TEXT, telefon TEXT, website TEXT, + glaeubigerid TEXT, iban TEXT, bic TEXT, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + rolle TEXT DEFAULT NULL, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS gruppen ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + name TEXT NOT NULL, + beschreibung TEXT, + trainer_ids TEXT NOT NULL DEFAULT '[]', + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS mitglieder ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + vorname TEXT NOT NULL, + nachname TEXT NOT NULL, + email TEXT, telefon TEXT, + geburtsdatum TEXT, eintrittsdatum TEXT, austrittsdatum TEXT, + strasse TEXT, plz TEXT, ort TEXT, + iban TEXT, bic TEXT, + gruppe_ids TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'aktiv', + notizen TEXT, + mandatsreferenz TEXT, mandatsdatum TEXT, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS beitraege ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + name TEXT NOT NULL, + betrag REAL NOT NULL, + rhythmus TEXT NOT NULL DEFAULT 'jaehrlich', + beschreibung TEXT, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS einzuege ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + mitglied_id TEXT NOT NULL REFERENCES mitglieder(id) ON DELETE CASCADE, + beitrag_id TEXT NOT NULL REFERENCES beitraege(id), + betrag REAL NOT NULL, + faellig_am TEXT, + status TEXT NOT NULL DEFAULT 'ausstehend', + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS veranstaltungsorte ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + name TEXT NOT NULL, + adresse TEXT, + typ TEXT DEFAULT 'sonstiges', + aktiv INTEGER NOT NULL DEFAULT 1, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS ort_ausfaelle ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + ort_id TEXT NOT NULL REFERENCES veranstaltungsorte(id) ON DELETE CASCADE, + von TEXT NOT NULL, bis TEXT NOT NULL, grund TEXT, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS termine ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + titel TEXT NOT NULL, + beschreibung TEXT, + beginn TEXT NOT NULL, + ende TEXT, + ort TEXT, + ort_id TEXT REFERENCES veranstaltungsorte(id) ON DELETE SET NULL, + gruppe_ids TEXT NOT NULL DEFAULT '[]', + durchfuehrender_id TEXT, + verfuegbarkeit TEXT DEFAULT 'offen', + rrule TEXT, + serie_id TEXT, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS nachrichten ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + autor_id TEXT NOT NULL, + betreff TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + gruppe_ids TEXT NOT NULL DEFAULT '[]', + gesendet_am TEXT, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS neuigkeiten ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + autor_id TEXT NOT NULL, + autor_name TEXT NOT NULL DEFAULT '', + text TEXT, + medien TEXT NOT NULL DEFAULT '[]', + gruppe_ids TEXT NOT NULL DEFAULT '[]', + termin_id TEXT REFERENCES termine(id) ON DELETE SET NULL, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS reaktionen ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + beitrag_id TEXT NOT NULL REFERENCES neuigkeiten(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE(beitrag_id, user_id) +); + +CREATE TABLE IF NOT EXISTS einladungen ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))), + rolle TEXT NOT NULL DEFAULT 'trainer', + genutzt INTEGER NOT NULL DEFAULT 0, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +`; + +let _db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (_db) return _db; + const dir = dirname(DB_PATH); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + _db = new Database(DB_PATH); + _db.exec(SCHEMA); + return _db; +} + +export function newId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export function parseArr(val: unknown): string[] { + if (Array.isArray(val)) return val; + if (typeof val === 'string') { try { return JSON.parse(val); } catch { return []; } } + return []; +} + +export function toArr(val: unknown): string { + return JSON.stringify(Array.isArray(val) ? val : []); +} + +export function row>(r: T): T { + const out: Record = {}; + for (const [k, v] of Object.entries(r)) { + if (typeof v === 'string' && (k.endsWith('_ids') || k === 'medien' || k === 'trainer_ids')) { + out[k] = parseArr(v); + } else if (typeof v === 'number' && (k === 'aktiv' || k === 'dosb_mitglied' || k === 'genutzt')) { + out[k] = Boolean(v); + } else { + out[k] = v; + } + } + return out as T; +} + +export function rows>(rs: T[]): T[] { + return rs.map(r => row(r)); +} diff --git a/app/src/lib/styles/theme.css b/app/src/lib/styles/theme.css new file mode 100644 index 0000000..220bdcb --- /dev/null +++ b/app/src/lib/styles/theme.css @@ -0,0 +1,59 @@ +:root { + /* Primärfarbe */ + --c-primary: #1e40af; + --c-primary-dark: #1d3a9e; + --c-primary-light: #e0e7ff; + --c-primary-subtle: #eff6ff; + + /* Text */ + --c-text: #1e293b; + --c-text-secondary: #475569; + --c-text-muted: #64748b; + --c-text-hint: #94a3b8; + + /* Hintergrund & Rahmen */ + --c-border: #e2e8f0; + --c-bg: #f1f5f9; + --c-bg-card: #ffffff; + --c-bg-subtle: #f8fafc; + + /* Dunkel (Theme-Farbe, Header, PWA) */ + --c-dark: #0f172a; + + /* Fehler / Rot */ + --c-error: #dc2626; + --c-error-dark: #b91c1c; + --c-error-light: #fca5a5; + --c-error-bg: #fee2e2; + --c-error-subtle: #fef2f2; + + /* Erfolg / Grün */ + --c-success: #16a34a; + --c-success-light: #86efac; + --c-success-bg: #dcfce7; + + /* Warnung / Gelb-Amber */ + --c-warning: #f59e0b; + --c-warning-light: #fde047; + --c-warning-bg: #fef9c3; + --c-warning-subtle: #fffbeb; + --c-warning-dark: #92400e; + --c-warning-darker: #713f12; + + /* Akzent / Lila (Plan-Badges) */ + --c-accent: #7c3aed; + --c-accent-subtle: #ede9fe; + + /* Primärfarbe – weitere Töne */ + --c-primary-100: #bfdbfe; + --c-primary-200: #c7d2fe; + --c-primary-bg: #f0f9ff; + + /* Erfolg – weitere Töne */ + --c-success-dark: #166534; + --c-success-subtle: #f0fdf4; + + /* Warnung – weitere Töne */ + --c-warning-amber: #854d0e; + --c-warning-pale: #fefce8; +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 07bad1f..159b98b 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1,18 +1,23 @@ 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 type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen' | 'storniert'; +export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich' | 'einmalig'; export interface Verein { id: string; name: string; - adresse: string; - plz: string; - ort: string; - bundesland: string; + adresse?: string; + plz?: string; + ort?: string; + bundesland?: string; plan: Plan; - stripe_customer_id?: string; dosb_mitglied: boolean; + email?: string; + telefon?: string; + website?: string; + glaeubigerid?: string; + iban?: string; + bic?: string; } export interface Mitglied { @@ -20,26 +25,77 @@ export interface Mitglied { verein_id: string; vorname: string; nachname: string; - email: string; + email?: string; telefon?: string; geburtsdatum?: string; - eintrittsdatum: string; + eintrittsdatum?: string; austrittsdatum?: string; - strasse: string; - plz: string; - ort: string; + strasse?: string; + plz?: string; + ort?: string; iban?: string; bic?: string; gruppe_ids: string[]; status: MitgliedStatus; notizen?: string; + mandatsreferenz?: string; + mandatsdatum?: string; } +export type Rolle = 'admin' | 'trainer'; + +export interface Neuigkeit { + id: string; + verein_id: string; + autor_id: string; + autor_name?: string; + text?: string; + medien: string[]; + gruppe_ids: string[]; + termin_id?: string; + created: string; +} + +export interface Reaktion { + id: string; + beitrag_id: string; + user_id: string; +} +export type Verfuegbarkeit = 'offen' | 'bestaetigt' | 'abgesagt' | 'vertretung_gesucht'; + export interface Gruppe { id: string; verein_id: string; name: string; beschreibung?: string; + trainer_ids: string[]; +} + +export type OrtTyp = 'halle' | 'platz' | 'gebaeude' | 'sonstiges'; + +export interface Veranstaltungsort { + id: string; + verein_id: string; + name: string; + adresse?: string; + typ?: OrtTyp; + aktiv: boolean; +} + +export interface OrtAusfall { + id: string; + ort_id: string; + von: string; + bis: string; + grund?: string; +} + +export interface Einladung { + id: string; + verein_id: string; + rolle: Rolle; + token: string; + genutzt: boolean; } export interface Beitrag { @@ -53,13 +109,11 @@ export interface Beitrag { export interface Einzug { id: string; - verein_id: string; mitglied_id: string; beitrag_id: string; betrag: number; - faelligkeitsdatum: string; + faellig_am?: string; status: EinzugStatus; - stripe_payment_intent_id?: string; } export interface Termin { @@ -71,6 +125,11 @@ export interface Termin { ende?: string; ort?: string; gruppe_ids: string[]; + durchfuehrender_id?: string; + verfuegbarkeit?: Verfuegbarkeit; + rrule?: string; + serie_id?: string; + ort_id?: string; } export interface Nachricht { @@ -80,5 +139,5 @@ export interface Nachricht { betreff: string; text: string; gruppe_ids: string[]; - gesendet_am: string; + gesendet_am?: string; } diff --git a/app/src/lib/user.ts b/app/src/lib/user.ts new file mode 100644 index 0000000..42f59a7 --- /dev/null +++ b/app/src/lib/user.ts @@ -0,0 +1,34 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface AppUser { + id: string; + verein_id: string; + rolle: string | null; + name: string; + email: string; + token: string; +} + +function createUserStore() { + let initial: AppUser | null = null; + if (browser) { + try { initial = JSON.parse(localStorage.getItem('vh_user') || 'null'); } catch { /* */ } + } + + const { subscribe, set } = writable(initial); + + return { + subscribe, + set(u: AppUser | null) { + if (browser) { + if (u) localStorage.setItem('vh_user', JSON.stringify(u)); + else localStorage.removeItem('vh_user'); + } + set(u); + }, + clear() { this.set(null); } + }; +} + +export const user = createUserStore(); diff --git a/app/src/routes/(app)/+layout.svelte b/app/src/routes/(app)/+layout.svelte index b435cee..d6eec7c 100644 --- a/app/src/routes/(app)/+layout.svelte +++ b/app/src/routes/(app)/+layout.svelte @@ -2,34 +2,82 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { onMount } from 'svelte'; - import { pb } from '$lib/pb'; + import { user } from '$lib/user'; + import Icon from '$lib/components/Icon.svelte'; + import type { IconName } from '$lib/icons'; let { children } = $props(); + let zeigInstallBanner = $state(false); + onMount(() => { - if (!pb.authStore.isValid) { - goto('/login'); - } + if (!$user) { goto('/login'); return; } + if (!$user.verein_id) { goto('/onboarding'); return; } + registerPush(); + + // iOS Safari: Banner anzeigen wenn App noch nicht installiert + const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent); + const isStandalone = window.matchMedia('(display-mode: standalone)').matches + || (navigator as any).standalone === true; + const dismissed = sessionStorage.getItem('install-banner-dismissed'); + if (isIos && !isStandalone && !dismissed) zeigInstallBanner = true; }); - function logout() { - pb.authStore.clear(); - goto('/login'); + 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); + } } - 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: '✉️' }, + 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()));
- - + + + +
@@ -42,13 +90,23 @@ href={item.href} class:active={$page.url.pathname === item.href} > - {item.icon} + {item.label} {/each}
+{#if zeigInstallBanner} +
+
+ Zum Homescreen hinzufügen + Tippe auf TeilenZum Home-Bildschirm für die Vollbild-App +
+ +
+{/if} + diff --git a/app/src/routes/(app)/+page.svelte b/app/src/routes/(app)/+page.svelte index 32a9972..1074e92 100644 --- a/app/src/routes/(app)/+page.svelte +++ b/app/src/routes/(app)/+page.svelte @@ -1,72 +1,141 @@ Übersicht — vereins.haus -

Willkommen, {vereinsname}

+{#if !loading && verein} +
+ +
+

{verein.name}

+ {#if verein.ort} + {verein.ort} + {/if} +
+
- + {#if termine.length > 0} +
+

Nächste Termine

+
    + {#each termine as t (t.id)} +
  • + {t.titel} + {formatTermin(t)} +
  • + {/each} +
+ Alle Termine → +
+ {:else} +

Noch keine Termine geplant.

+ {/if} +{/if} diff --git a/app/src/routes/(app)/beitraege/+page.svelte b/app/src/routes/(app)/beitraege/+page.svelte index d1494b1..306fc99 100644 --- a/app/src/routes/(app)/beitraege/+page.svelte +++ b/app/src/routes/(app)/beitraege/+page.svelte @@ -1,27 +1,531 @@ Beiträge — vereins.haus -