187 Commits

Author SHA1 Message Date
6e00a1b829 Merge pull request 'dev' (#34) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 33s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #34
2026-05-27 20:43:46 +02:00
Torsten Schulz (local)
533e89c405 chore(ci): trigger analyze job after lint fixes
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m12s
2026-05-27 20:34:27 +02:00
Torsten Schulz (local)
083244bc83 chore(lint): run eslint --fix; add safe global fallbacks and fix empty catches
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-27 20:33:08 +02:00
Torsten Schulz (local)
9def0fdc32 chore(lint): add safe fallbacks for Nitro globals (getMethod/getRequestURL) in passkey and middleware handlers
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 20:02:46 +02:00
Torsten Schulz (local)
512756cb48 chore(lint): manual fixes - remove redundant global declarations; add safe getMethod fallback; remove unused catch vars
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-27 20:00:48 +02:00
Torsten Schulz (local)
19d2f21fc3 refactor(security): use centralized getServerDataPath in password-reset-log.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-05-27 19:55:11 +02:00
Torsten Schulz (local)
5074e8f8f8 fix(security): centralize data path validation in getServerDataPath; enforce segment whitelist and resolved-path check
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-05-27 19:53:59 +02:00
Torsten Schulz (local)
18a08b0e7a fix(security): validate resolved data path to prevent path traversal
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-27 19:51:15 +02:00
Torsten Schulz (local)
026e4ba3e4 fix(password-reset-log): improve filename sanitization and error handling in getDataPath
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m3s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 19:41:12 +02:00
Torsten Schulz (local)
58fd7fa5c6 feat(auth): implement Android refresh token handling and session management
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
2026-05-27 19:34:53 +02:00
755442fb70 Merge branch 'main' into dev
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-05-27 19:05:18 +02:00
d31515bc79 Merge pull request 'dev' (#33) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 4m37s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #33
2026-05-27 19:04:15 +02:00
Torsten Schulz (local)
1710c9349d feat(auth): implement token rotation and session management for persistent Android login
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m22s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m12s
2026-05-27 18:55:22 +02:00
Torsten Schulz (local)
cd025b1f92 fix(package): update qs to version 6.15.2
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m15s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 4m49s
2026-05-27 18:23:16 +02:00
Torsten Schulz (local)
2c681cf65c Android-Cleanup und build fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 18:12:10 +02:00
Torsten Schulz (local)
92099685e6 Saison-Auswahl hinzugefügt
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m17s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 18:01:32 +02:00
Torsten Schulz (local)
7e0c92368e Android-Umsetzung der Homepage
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m22s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 17:54:24 +02:00
Torsten Schulz (local)
817f5e02ca feat(gradle): add Kotlin and new DSL configuration options
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 17:48:38 +02:00
Torsten Schulz (local)
d3be0a269f feat(theme): add color and typography definitions for the app
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m52s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-26 16:43:17 +02:00
Torsten Schulz (local)
8e318b0b52 feat(android): initial project setup with Gradle, AndroidManifest, and MainActivity
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-26 16:39:10 +02:00
b729e90e38 Merge pull request 'dev' (#32) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m53s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m7s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #32
2026-05-22 14:05:21 +02:00
Torsten Schulz (local)
acfcf773f7 fix(version): update version to 1.5.2 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m6s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m13s
2026-05-22 13:54:22 +02:00
Torsten Schulz (local)
1ea7f7a63f refactor(vereinsmeisterschaften): streamline file existence check and improve error handling
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-22 13:54:02 +02:00
Torsten Schulz (local)
7289adb7a0 test: avoid gitleaks false positive in profile spec
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m51s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m6s
2026-05-21 08:18:30 +02:00
Torsten Schulz (local)
19d7aeefb0 test: expand endpoint coverage and harden deploy gate
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 15s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-21 08:03:59 +02:00
92c0610dcb Merge pull request 'dev' (#31) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #31
2026-05-21 00:09:31 +02:00
Torsten Schulz (local)
5fce08ab75 fix(version): update version to 1.5.1 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-21 00:08:04 +02:00
Torsten Schulz (local)
1c9dff0932 feat(mannschaften): show rise/fall arrow in table rank column
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-21 00:07:18 +02:00
Torsten Schulz (local)
fd83b18642 fix(import): prefer seasonal mannschaften csv for tables
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-05-20 18:58:46 +02:00
6db05c1dc6 Merge pull request 'dev' (#30) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #30
2026-05-20 18:42:31 +02:00
Torsten Schulz (local)
84536956c4 fix(version): update version to 1.5.0 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-20 18:42:04 +02:00
Torsten Schulz (local)
459da00820 fix(import): publish season spielplan json after import
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-05-20 18:12:32 +02:00
Torsten Schulz (local)
f883d45452 fix(security): harden season file paths for semgrep
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
2026-05-20 18:03:29 +02:00
Torsten Schulz (local)
11ff823fe2 feat(mannschaften): align public pages with season query logic
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-20 18:01:07 +02:00
Torsten Schulz (local)
cfd9365d07 feat(mannschaften): update team information display and add data update timestamps
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:57:02 +02:00
Torsten Schulz (local)
f2f76dec56 feat(cms): add season dropdown/create and restore baelle ratio
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:49:19 +02:00
Torsten Schulz (local)
2d42ef3ecd feat(mannschaften): split SUN columns and prepare seasonal team CSVs
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m40s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:45:14 +02:00
Torsten Schulz (local)
e19158558d feat(mannschaften): add matches/table tabs on team detail pages
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:41:43 +02:00
Torsten Schulz (local)
bf4db389ff feat(api): add endpoint for team table data by season
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:38:17 +02:00
Torsten Schulz (local)
964a68cdfd refactor(import): drop meetings excerpt from tables export
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-20 17:36:10 +02:00
Torsten Schulz (local)
21b39d4e5c feat(import): add daily click-tt league table import by season
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 17:28:07 +02:00
68b6ab593a Merge pull request 'fix(ci): avoid shell-expansion in node version-compare helper' (#29) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m46s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #29
2026-05-20 17:08:28 +02:00
Torsten Schulz (local)
3658589d94 fix(ci): avoid shell-expansion in node version-compare helper
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-20 17:03:04 +02:00
4e42ddfee4 Merge pull request 'ci: ensure version-check scripts are executable; invoke via bash' (#28) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #28
2026-05-20 17:00:19 +02:00
Torsten Schulz (local)
a80ea7b892 ci: ensure version-check scripts are executable; invoke via bash
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-05-20 16:54:45 +02:00
6a6b8e0a1b Merge pull request 'ci: disable PR version-gate; use prod-version check on main' (#27) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 16s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #27
2026-05-20 16:11:57 +02:00
Torsten Schulz (local)
f7c6caebc1 ci: disable PR version-gate; use prod-version check on main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m42s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-20 16:08:09 +02:00
ddb170cffc Merge pull request 'chore: bump version to 1.4.7' (#26) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #26
2026-05-20 15:46:46 +02:00
Torsten Schulz (local)
79e2ae9b87 chore: bump version to 1.4.7
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 15:39:19 +02:00
66f2718714 Merge pull request 'ci: run analyze only on push; remove PR-specific checks' (#25) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #25
2026-05-20 13:16:41 +02:00
Torsten Schulz (local)
4891e965bb ci: run analyze only on push; remove PR-specific checks
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
Require Package Version Change / check (pull_request) Failing after 9s
2026-05-20 13:11:01 +02:00
f2f5ddb8ce Merge pull request 'dev' (#24) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #24
2026-05-20 13:10:27 +02:00
Torsten Schulz (local)
2fab6be58a ci: run version-change check only for PRs and main pushes (allow dev pushes)
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 12s
Require Package Version Change / check (pull_request) Failing after 9s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:56:40 +02:00
Torsten Schulz (local)
549f4a1510 ci: add production version check for PRs
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 12:53:46 +02:00
4d13ea5de6 Merge pull request 'dev' (#23) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #23
2026-05-20 12:45:58 +02:00
Torsten Schulz (local)
02ee4af49d chore: remove unused createRequire import from generate-pdf.post.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 13s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:27:48 +02:00
Torsten Schulz (local)
cc253c24db ci: remove version-gate workflow; bump version to 1.4.6
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-20 12:26:47 +02:00
45d343b1c1 Merge pull request 'dev' (#22) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #22
2026-05-20 12:24:53 +02:00
Torsten Schulz (local)
2eeed60387 ci: trigger analyze job test (noop)
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Code Analysis and Production Deploy / analyze (pull_request) Failing after 12s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:23:45 +02:00
Torsten Schulz (local)
63a1034e3d ci: include version-gate check in analyze job to block deploys on version mismatch
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 12:20:51 +02:00
7b9bdb9a36 Merge pull request 'dev' (#21) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m43s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #21
2026-05-20 12:09:48 +02:00
Torsten Schulz (local)
1561e1b861 chore: regenerate package-lock (remove bundled npm)
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m3s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m44s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-05-20 12:06:20 +02:00
Torsten Schulz (local)
3f7149d622 chore: bump version to 1.4.5
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m38s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 11:54:48 +02:00
Torsten Schulz (local)
bc9cc265e1 ci: require analysis before deploy; make deploy jobs depend on analyze
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m36s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-20 11:50:47 +02:00
a48c65bfb8 Merge pull request 'dev' (#20) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #20
2026-05-20 11:46:54 +02:00
Torsten Schulz (local)
fdf72bdb96 chore: ensure npm 10.9.7 in engines, add build script, remove npm dep
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m40s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:38:30 +02:00
Torsten Schulz (local)
dbcc55d7fc Aktualisiere die Version auf 1.4.4 und stelle sicher, dass npm auf die Version 10.9.7 gesetzt ist
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 34s
2026-05-20 11:34:48 +02:00
Torsten Schulz (local)
f8a0370910 chore: regenerate package-lock using npm@10.9.7 for CI compatibility
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 36s
2026-05-20 11:33:37 +02:00
9a59fa5525 Merge pull request 'chore: bump version to 1.4.3; add logging and season-slug validation' (#19) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Reviewed-on: #19
2026-05-20 11:31:42 +02:00
Torsten Schulz (local)
41374da6ea chore: bump version to 1.4.3; add logging and season-slug validation
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 7s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 16s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:31:03 +02:00
70acfe6d5e Merge pull request 'Aktualisiere die Version auf 1.4.3, füge Validierung für Saison-Slugs hinzu und implementiere ein Logging-System für Fehler und Informationen' (#18) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m51s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #18
2026-05-20 11:23:50 +02:00
Torsten Schulz (local)
e5c247f703 Aktualisiere die Version auf 1.4.3, füge Validierung für Saison-Slugs hinzu und implementiere ein Logging-System für Fehler und Informationen
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m49s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m37s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:20:54 +02:00
01f7d075e9 Merge pull request 'Aktualisiere die Version auf 1.4.2 und füge Funktionen zur Fehlerprotokollierung und Validierung von Saison-Slugs hinzu' (#17) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m52s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #17
2026-05-20 11:09:48 +02:00
Torsten Schulz (local)
2503eb92af Aktualisiere die Version auf 1.4.2 und füge Funktionen zur Fehlerprotokollierung und Validierung von Saison-Slugs hinzu
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m51s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m43s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
2026-05-20 11:07:56 +02:00
bb2785ba56 Merge pull request 'Refactor code structure for improved readability and maintainability' (#16) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m54s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #16
2026-05-20 10:59:14 +02:00
Torsten Schulz (local)
4e918870f5 Refactor code structure for improved readability and maintainability
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m51s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 10s
2026-05-20 10:52:28 +02:00
e871790ee8 Merge pull request 'Add script for importing match schedule and logging' (#15) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #15
2026-05-20 10:34:07 +02:00
Torsten Schulz (local)
0849c625cb Add script for importing match schedule and logging
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 33s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 10s
- Created `import-spielplan.js` to fetch and parse the match schedule from the specified URL, saving the output as JSON.
- Added `run-spielplan-import.sh` to automate the execution of the import script and log output.
- Introduced `spielplan.html` file to store the downloaded HTML content for further processing.
2026-05-19 16:23:28 +02:00
b19a9f6b8e Merge pull request 'dev' (#14) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m2s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #14
2026-05-15 14:30:32 +02:00
Torsten Schulz (local)
c78adc0d52 Aktualisiere die Version auf 1.3.0 in der package.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
2026-05-15 14:29:52 +02:00
Torsten Schulz (local)
806a4be2f9 Aktualisiere Hinweise zur Verwendung von Authenticatoren in Firefox und passe die bevorzugte Authenticator-Typen für die Passkey-Registrierung an.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
2026-05-15 14:24:40 +02:00
Torsten Schulz (local)
5f96f719c1 Füge Hinweise zur Verwendung externer Authenticatoren in Firefox hinzu und passe die Registrierung von Passkeys an.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-15 14:13:22 +02:00
Torsten Schulz (local)
28b1001826 Füge Hinweise zur Verwendung von Passkeys hinzu und aktualisiere die Beschreibung der Passkey-Anmeldung im Profil.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-05-15 14:05:18 +02:00
Torsten Schulz (local)
dcc4055eca Füge Unterstützung für Platform-Authentifikatoren hinzu und passe die Passkey-Registrierungsoptionen für Firefox an.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m56s
2026-05-15 14:01:18 +02:00
Torsten Schulz (local)
4521ce002e Füge Unterstützung für Firefox-Browser hinzu, indem die Registrierung von Passkeys ohne bevorzugten Authentifikator-Typ ermöglicht wird.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m52s
2026-05-15 13:56:12 +02:00
Torsten Schulz (local)
842a516ce6 Vereinfachung der Passkey-Registrierung durch Entfernen der bevorzugten Authentifikator-Typen und Anpassung der Fehlerbehandlung.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m52s
2026-05-15 13:36:53 +02:00
Torsten Schulz (local)
48f8b46e57 Erweitere die Passkey-Registrierung um Unterstützung für bevorzugte Authentifikatortypen und verbessere die Fehlerbehandlung.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
2026-05-15 13:30:15 +02:00
Torsten Schulz (local)
8ae7dcdbf1 Implement passkey login functionality and enhance passkey support checks
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s
2026-05-15 13:20:09 +02:00
667b65bf29 Merge pull request 'Enhance fillable template generation by adding logo and refactoring header drawing' (#13) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #13
2026-05-12 23:30:08 +02:00
Torsten Schulz (local)
dba2747883 Enhance fillable template generation by adding logo and refactoring header drawing
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m59s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Integrated a logo into the fillable PDF templates for improved branding.
- Refactored the header drawing logic in create-fillable-template.js to streamline the process and ensure consistency across multiple pages.
- Updated the membership and membership fillable PDF templates to reflect these changes.
2026-05-12 23:25:28 +02:00
21752fedcc Merge pull request 'Update package version to 1.2.0, enhance fillable template generation with underlined text fields, and update PDF templates' (#12) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m8s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #12
2026-05-12 23:05:16 +02:00
Torsten Schulz (local)
a30692a053 Update package version to 1.2.0, enhance fillable template generation with underlined text fields, and update PDF templates
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m25s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Bumped the package version to 1.2.0 in package.json.
- Refactored the fillable template generation script to use a new function for creating underlined text fields, improving code readability and maintainability.
- Updated the membership and membership fillable PDF templates to reflect changes in the form fields.
2026-05-12 23:00:07 +02:00
742687f82b Merge pull request 'dev' (#11) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #11
2026-05-06 16:06:18 +02:00
Torsten Schulz (local)
fddde56076 Update package version to 1.1.6 and enhance deploy script with dependency checks
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m29s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m7s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Bumped the package version to 1.1.6 in package.json.
- Added `install_dependencies_if_needed` function in deploy-test.sh to conditionally install dependencies based on the state of package-lock.json.
- Improved logging of build output to a file for better error tracking during deployment.
- Updated Nuxt cache handling to retain .nuxt directory for faster builds unless explicitly cleaned.
2026-05-06 16:03:16 +02:00
Torsten Schulz (local)
c385df4a0c Add dependency installation check and logging to deploy script
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m54s
- Introduced `install_dependencies_if_needed` function to conditionally install dependencies based on the presence and changes in `package-lock.json`.
- Updated the deployment process to log build output to a file for better error tracking.
- Modified Nuxt configuration to disable source maps in production and prevent reporting of compressed sizes in Vite builds.
2026-05-06 15:58:02 +02:00
7152b54b68 Merge pull request 'Update candidate paths for CSV file retrieval in mannschaften.get.js' (#10) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m14s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #10
2026-05-05 15:20:52 +02:00
Torsten Schulz (local)
e44d3c5c74 Update package version to 1.1.5 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m5s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m35s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Successful in 11s
2026-05-05 15:14:38 +02:00
Torsten Schulz (local)
c409fa6d4b Update candidate paths for CSV file retrieval in mannschaften.get.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
- Adjusted the logic to prioritize the new CMS write target for public data.
- Updated comments to clarify the order of candidate paths for file retrieval.
2026-05-05 15:13:22 +02:00
21b4e8bc9f Merge pull request 'dev' (#9) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #9
2026-04-27 16:52:43 +02:00
Torsten Schulz (local)
0fa19493c5 Refactor readPackageVersion function to support multiple candidate paths for package.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
- Updated the logic to read the package version from either the current directory or the parent directory.
- Added error handling to continue searching through candidate paths if the first read fails.
2026-04-27 16:52:12 +02:00
Torsten Schulz (local)
c145a723ed Update package version to 1.1.4 in package.json and package-lock.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-04-27 16:47:57 +02:00
91fb3d79c5 Merge pull request 'dev' (#8) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m8s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #8
2026-04-27 16:46:55 +02:00
Torsten Schulz (local)
d0b15f3e83 Update package version to 1.1.3 and postcss dependency to 8.5.12 in package.json and package-lock.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m4s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m25s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
2026-04-27 16:40:33 +02:00
Torsten Schulz (local)
e60c0f4481 Add logic to include active trainers as newsletter recipients
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m16s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Successful in 11s
- Enhanced the getRecipientsByGroup function to filter and add active trainers from users.json to the newsletter recipients list.
- Ensured that duplicate emails are not added to the recipients array.
2026-04-27 15:10:57 +02:00
Torsten Schulz (local)
27a096546f Implement user sorting feature in Benutzer.vue
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
- Added a dropdown for sorting active users by first name or last name.
- Updated the display of active users to reflect the selected sorting order.
- Introduced helper functions to split names and format display names accordingly.
2026-04-27 15:04:41 +02:00
aee8705fa3 Merge pull request 'dev' (#7) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m56s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #7
2026-04-16 14:06:43 +02:00
Torsten Schulz (local)
20a1cdd7f2 Update package version to 1.1.2 in package.json and modify code-analysis.yml to trigger analysis only on pull requests.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m49s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 7s
2026-04-16 13:53:31 +02:00
Torsten Schulz (local)
e3825ad217 Update package version to 1.1.1 in package.json for the Harheimer Tischtennis Club website.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-04-16 13:45:02 +02:00
99c03dccf2 Merge pull request 'Remove package version change requirement for main PRs in code-analysis.yml to streamline workflow.' (#6) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m55s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #6
2026-04-16 13:41:06 +02:00
Torsten Schulz (local)
a12f1f7815 Remove package version change requirement for main PRs in code-analysis.yml to streamline workflow.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m54s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m49s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-04-16 13:36:45 +02:00
d450175871 Merge pull request 'dev' (#5) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #5
2026-04-16 13:23:53 +02:00
Torsten Schulz (local)
6fea2749e0 Add app version display in Footer and implement version API endpoint
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m54s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 11s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
- Updated Footer.vue to show the application version for logged-in users.
- Added a new API endpoint to return the application version from package.json.
- Enhanced code-analysis.yml to require package version changes for main PRs.
2026-04-16 13:16:53 +02:00
Torsten Schulz (local)
18da725567 Refactor deployment scripts to use git fetch and reset for pulling latest changes. Update deploy-production.sh and deploy-test.sh to ensure a clean state before deployment. Modify code-analysis.yml to reflect these changes in deployment commands.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m58s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-04-16 13:11:23 +02:00
Torsten Schulz (local)
4d5fb43ebc Enhance deploy-test.sh with functions for Node.js version management, dependency installation, and public document synchronization. Implement checks for Node.js version requirements and improve error handling for document syncing. Update environment configuration in harheimertc.test.config.cjs to support development and test environments. Modify email recipient logic in contact and email service APIs to prevent notifications in test environments. Add tests to verify behavior in test conditions.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m52s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 12s
2026-04-16 13:06:14 +02:00
Torsten Schulz (local)
986b2056cd Enhance deploy-production.sh with new functions for Node.js version management and public document synchronization. Added checks for Node.js version requirements and improved error handling for document syncing. Updated package.json and package-lock.json to specify Node.js and npm engine requirements.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m57s
2026-04-15 21:51:08 +02:00
Torsten Schulz (local)
337c172d07 Refactor dependency installation in deploy-production.sh to use a dedicated function. This improves error handling for missing package-lock.json and ensures consistent installation behavior. Removed obsolete public-data restoration logic for cleaner script execution.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m2s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m0s
2026-04-15 21:43:32 +02:00
Torsten Schulz (local)
15b8f3c4c1 Update version in package.json from 1.0.0 to 1.1.0 for the Harheimer Tischtennis Club website.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 3m49s
Code Analysis and Production Deploy / deploy-production (push) Failing after 0s
2026-04-15 21:36:37 +02:00
Torsten Schulz (local)
510cfd39f9 Update code-analysis workflow to include production deployment steps and rename workflow for clarity. Add SSH setup and connection testing for secure deployment to production environment.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Failing after 1s
2026-04-15 21:30:09 +02:00
Torsten Schulz (local)
e0bad51764 Update commander dependency to version 13.1.0 in package.json and package-lock.json for improved functionality and compatibility.
All checks were successful
Code Analysis (JS/Vue) / analyze (push) Successful in 3m10s
2026-04-15 21:18:26 +02:00
Torsten Schulz (local)
c1de0c1671 Enhance deploy-production.sh with error handling for git pull failures. Provide user guidance for SSH key setup and switching to HTTPS if necessary. Update code-analysis.yml to include Node.js setup with caching for improved workflow efficiency.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m25s
2026-04-15 21:09:04 +02:00
Torsten Schulz (local)
2bedbee08d Upgrade nodemailer to latest major for audit compliance.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 10s
This removes the remaining SMTP command injection advisories by moving to nodemailer 8.0.5 and refreshing the lockfile accordingly.

Made-with: Cursor
2026-04-15 21:00:43 +02:00
Torsten Schulz (local)
9c54b6907e Apply non-major audit updates and harden path handling for Semgrep.
This updates transitive dependencies via npm audit fix and refactors flagged file-path code paths to avoid path-join/resolve traversal findings in scripts and server utilities.

Made-with: Cursor
2026-04-15 21:00:28 +02:00
Torsten Schulz (local)
edfab28fd3 Add security comments to path handling in various scripts to clarify internal constant usage and mitigate path traversal risks. Update logging in registration and verification processes for improved clarity.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 2m48s
2026-04-15 20:52:38 +02:00
Torsten Schulz (local)
5f79d220cf Refactor PDF generation process in membership API to ensure consistent directory creation for uploads. Update final PDF path handling to improve clarity and maintainability of the code.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-04-15 20:50:29 +02:00
Torsten Schulz (local)
0a82b33afc Refactor PDF generation logic in membership API to improve error handling and enhance font embedding. Update LaTeX template for German language support and streamline debugging messages. Ensure encrypted data handling is consistent and improve command execution error management for PDF generation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 3m6s
2026-04-15 20:46:18 +02:00
Torsten Schulz (local)
1922e85184 Add mock implementations for role checks in auth utility and enhance sharp mock with image metadata retrieval
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-04-15 20:44:54 +02:00
Torsten Schulz (local)
0fb8052a77 Enhance deploy-test.sh script with error handling for root execution and write permissions. Add checks for successful git pull and provide user guidance for SSH key setup and repository access issues.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-04-15 20:39:59 +02:00
Torsten Schulz (local)
ef2d9353f5 Enhance ESLint configuration to include support for .mjs and .cjs file types. Update ignored files patterns to ensure proper linting of project files. Refactor Vue component templates for improved readability and maintainability, including consistent formatting and structure across various components. Update error handling in save functions to prevent silent failures.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-04-15 20:37:14 +02:00
Torsten Schulz (local)
1aae808e5f Update beitrittserklärung template PDF to the latest version.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m4s
2026-04-15 20:28:36 +02:00
Torsten Schulz (local)
75e6d66d25 Update German text for clarity in member management UI and API error messages, correcting "Fuer" to "Für" and "koennen" to "können" for improved readability.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 55s
2026-04-01 11:55:27 +02:00
Torsten Schulz (local)
daabeec33c Implement birthdate input in member profile management. Update API to handle birthdate data for user profiles and enhance visibility settings for birthday display in member lists.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m4s
2026-04-01 11:19:23 +02:00
Torsten Schulz (local)
0fb58af194 Add birthdate handling in member registration and management. Update UI to conditionally require birthdate for new members, and enhance API to enforce birthdate validation. Improve tests to cover new birthdate requirements.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-31 07:25:44 +02:00
Torsten Schulz (local)
8ffd267dfc Enhance member management UI by adding hall key status display and editing capabilities. Update API to support hall key data integration in member records.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-03-30 15:41:16 +02:00
Torsten Schulz (local)
5eee7df7e4 Refactor authentication logic in members API to use getUserFromToken for user retrieval. Update error messages for better clarity and enhance tests to reflect changes in authentication handling.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m2s
2026-03-30 15:29:25 +02:00
Torsten Schulz (local)
7dea265eef Add filter option for members with hall key in member management UI. Update member listing logic to reflect filtered results based on hall key status.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-29 15:25:27 +02:00
Torsten Schulz (local)
381ec55fd1 Refactor member acceptance logic in API to improve handling of legacy records. Remove debug logging and clarify acceptance criteria for manual members.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-29 15:00:53 +02:00
Torsten Schulz (local)
c30911daed Refactor saveMember function to enhance duplicate member handling, allowing updates to existing duplicates and improving error handling for member existence checks.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-03-29 14:54:18 +02:00
Torsten Schulz (local)
bdc9eef707 Refactor saveMember function to improve duplicate member handling and streamline member updates. Enhance error messaging for non-existent members and ensure new members are added with default active status.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-29 14:49:04 +02:00
Torsten Schulz (local)
f7701d698f Add hall key feature to member management, including UI updates for displaying and editing hall key status. Update API to handle hall key data in member records.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m6s
2026-03-29 14:37:49 +02:00
Torsten Schulz (local)
49e7255062 Enhance CSV saving functionality by adding token retrieval from authorization header if not present in cookies. Update tests to validate CSV saving for users with 'vorstand' role.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-18 13:12:32 +01:00
Torsten Schulz (local)
74246e6b08 Implement status toggle functionality for contact requests, updating the status display and adding error handling. Enhance the UI with a new button for marking requests as completed or reopening them.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-11 21:16:03 +01:00
Torsten Schulz (local)
6230c96bc9 Refactor links section to use dynamic rendering with computed properties, enhancing maintainability and scalability. Add new 'Links' tab in CMS for better navigation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-04 16:05:34 +01:00
Torsten Schulz (local)
3fb40bd87d Erweitere die Navigation um einen neuen Link zu "Links" und aktualisiere die Logik zur Bestimmung des aktuellen Submenüs, um die neue Route zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-04 14:53:11 +01:00
Torsten Schulz (local)
46c2c14ae8 Füge Unterstützung für Kontaktanfragen hinzu, einschließlich neuer Routen und Berechtigungen für Trainer und Vorstand. Aktualisiere E-Mail-Versandlogik, um Anfragen an alle relevanten Empfänger weiterzuleiten.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-26 14:28:54 +01:00
Torsten Schulz (local)
ff8c1970df Ersetze Willkommensnachricht durch Geburtstags-Widget mit dynamischer Anzeige der nächsten Geburtstage
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 16:39:52 +01:00
Torsten Schulz (local)
8347a86727 Entferne die JSON-Darstellung des Mitglieds aus der Mitgliederansicht
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-02-14 16:26:58 +01:00
Torsten Schulz (local)
9a6d32dcb3 Füge ESM-Importe und Skriptbeschreibung für das Aufteilen von Namen in Benutzer- und Bewerbungsdateien hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 16:25:29 +01:00
Torsten Schulz (local)
161618f6fb Füge Skripte zum Aufteilen von Namen in firstName und lastName für Mitglieder und Bewerbungen hinzu, einschließlich Backup-Funktionalität.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 15:58:11 +01:00
Torsten Schulz (local)
0b3fba44a4 Füge Skript zum Aufteilen von Namen in firstName und lastName für Benutzer hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 15:50:37 +01:00
Torsten Schulz (local)
d35e1c9a3e Füge Vorname und Nachname in das Registrierungsformular und die Mitgliederverwaltung ein
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 15:48:56 +01:00
Torsten Schulz (local)
528353132a Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:45:51 +01:00
Torsten Schulz (local)
cd5e5cd781 Füge die Anzeige der Mitgliederdaten im JSON-Format in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:42:58 +01:00
Torsten Schulz (local)
ebbffcc5c4 Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:38:39 +01:00
Torsten Schulz (local)
5c760d7fa8 Füge Sichtbarkeits-Flags für E-Mail und Telefon in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 03:31:28 +01:00
Torsten Schulz (local)
d40073ac7b Füge Sichtbarkeits-Flags für E-Mail, Telefon, Adresse und Geburtstag in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:23:59 +01:00
Torsten Schulz (local)
b25cf13d3c Füge Sichtbarkeits-Flags für Mitglieder hinzu, um die Anzeige von E-Mail, Telefon, Adresse und Geburtstag zu steuern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:16:28 +01:00
Torsten Schulz (local)
3287102761 Füge Vorname und Nachname zu den Mitgliederdaten hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 03:08:19 +01:00
Torsten Schulz (local)
08624cabbe Verbessere die Sichtbarkeit von Mitgliederdaten, indem das Geburtsdatum im Edit-Formular hinzugefügt wird
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:05:15 +01:00
Torsten Schulz (local)
d37f182928 Füge Skript hinzu, um Sichtbarkeitsflags für Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-14 02:58:30 +01:00
Torsten Schulz (local)
79c45be7c7 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:52:44 +01:00
Torsten Schulz (local)
d52f3ffc8d Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:50:57 +01:00
Torsten Schulz (local)
64baaf8535 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:48:30 +01:00
Torsten Schulz (local)
e665495003 Verbessere die Sortierlogik in der Mitgliederliste für Namen, Nachnamen und Geburtstage mit robusteren Vergleichen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:42:43 +01:00
Torsten Schulz (local)
8f444c59eb Füge Sortieroptionen zur Mitgliederliste hinzu und verbessere die Sortierung nach Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:36:26 +01:00
Torsten Schulz (local)
8117335af9 Entferne die Sortieroptionen und passe die Mitgliederanzeige an, um die Sortierung direkt aus der Mitgliederliste zu entfernen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-02-14 02:36:19 +01:00
Torsten Schulz (local)
85ec99b08c Optimiere das Template der Mitgliederliste durch Entfernen von überflüssigem Code und verbessere die Sortieroptionen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-02-14 02:28:40 +01:00
Torsten Schulz (local)
04571e6444 Verbessere die Struktur des Templates in der Mitgliederliste und füge Sortieroptionen hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:22:52 +01:00
Torsten Schulz (local)
5799f97570 Entferne überflüssige Zeile im Template der Mitgliederliste
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:17:27 +01:00
Torsten Schulz (local)
8ab08f4c09 Füge Header und Sortieroptionen zur Mitgliederliste hinzu
Some checks are pending
Code Analysis (JS/Vue) / analyze (push) Has started running
2026-02-14 02:16:36 +01:00
Torsten Schulz (local)
fcf3168692 Entferne überflüssige geschweifte Klammer in der formatDate-Funktion
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-14 02:13:25 +01:00
Torsten Schulz (local)
cfd209d7ee Filtere den Admin-Account aus der Mitgliederliste heraus, um die Sichtbarkeit zu verbessern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 02:07:45 +01:00
Torsten Schulz (local)
ee1709ffb2 Füge Sortieroptionen für Mitgliederliste hinzu und implementiere Sortierlogik nach Name, Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:06:36 +01:00
Torsten Schulz (local)
8bb02b6e4a Füge dotenv-Konfiguration zum Skript hinzu, um Umgebungsvariablen zu laden
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:00:39 +01:00
Torsten Schulz (local)
7a20af2772 Füge active-Feld zu Mitgliedsdaten hinzu und implementiere Skript zum Aktivieren aller Mitglieder
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 01:54:39 +01:00
Torsten Schulz (local)
3e610e68b6 Füge Debug-Logs hinzu, um alle geladenen Mitglieder (decryptet) anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 01:47:20 +01:00
Torsten Schulz (local)
485b21c13e Füge Diagnose-Skript hinzu, um Mitglieder aus members.json mit Status und Sichtbarkeit anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 01:43:18 +01:00
Torsten Schulz (local)
08b1edc354 Füge Skript zum Re-Encryptieren von Klartext-Mitgliedsanträgen hinzu; implementiere Backup-Funktion und Fehlerbehandlung
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 01:37:42 +01:00
Torsten Schulz (local)
6e297c682c Füge Geburtstags-Widget hinzu und implementiere Geburtstagsladefunktion; erweitere Sichtbarkeitseinstellungen für Geburtstage in Profil und API
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-13 17:27:27 +01:00
Torsten Schulz (local)
3d3e22bb1b Implementiere zentralen E-Mail-Service für Registrierungsbenachrichtigungen und entferne veralteten Code
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-11 15:41:03 +01:00
Torsten Schulz (local)
d18b671532 Ändere Sichtbarkeitseinstellungen für Mitglieder: Standardmäßig sichtbar für alle eingeloggten Mitglieder, es sei denn, sie sind explizit verborgen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:37:13 +01:00
Torsten Schulz (local)
af3c0164ef Füge Sichtbarkeitsoptionen für Mitglieder und registrierte Benutzer hinzu; aktualisiere die Sichtbarkeitseinstellungen basierend auf Benutzerpräferenzen in der Mitgliederabfrage und dem Sichtbarkeits-Skript.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-11 14:25:49 +01:00
Torsten Schulz (local)
c681194462 Make visibility opt-in by default; coerce visibility booleans; only 'vorstand' overrides
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:10:54 +01:00
Torsten Schulz (local)
141a15a6cb Respect per-user visibility; only 'vorstand' overrides visibility; UI shows contactHidden per-member
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:27:24 +01:00
Torsten (PC)
ce5915a3bc fixed .gitignore
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 13:08:07 +01:00
Torsten Schulz (local)
677140bd33 Füge Sichtbarkeitspräferenzen für Mitgliederprofile hinzu: Ermögliche Benutzern, ihre E-Mail, Telefonnummer und Adresse für andere eingeloggte Mitglieder sichtbar zu machen. Aktualisiere die API, um diese Einstellungen zu respektieren und bei der Profildatenrückgabe zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:04:45 +01:00
Torsten Schulz (local)
8a1e309eba Verbessere Mitgliederabfrage: Filtere manuelle Mitglieder nach aktiven/akzeptierten Status und entferne nicht benötigte Datenschutzlogik.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 44s
2026-02-11 12:49:14 +01:00
Torsten Schulz (local)
0d533710cd Refactor file handling to prioritize internal data directories for backups and uploads; enhance error handling and logging for metadata and CSV operations.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 11:42:24 +01:00
230 changed files with 21955 additions and 3896 deletions

0
.codex Normal file
View File

View File

@@ -1,9 +1,8 @@
name: Code Analysis (JS/Vue)
name: Code Analysis and Production Deploy
on:
pull_request:
push:
branches: [ main ]
branches: [ main, dev ]
jobs:
analyze:
@@ -12,6 +11,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Workspace sanity check
run: |
echo "PWD: $(pwd)"
@@ -25,6 +30,15 @@ jobs:
node -v
npm -v
- name: Check package.json version vs production
if: "github.ref == 'refs/heads/main'"
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_PORT: ${{ vars.PROD_PORT }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: bash scripts/check-version-against-prod.sh
- name: gitleaks (Secrets Scanning)
run: |
# Try to get the latest release asset URL
@@ -82,3 +96,65 @@ jobs:
./osv-scanner --version
test -f ./package-lock.json
./osv-scanner --lockfile ./package-lock.json
deploy-production:
needs: analyze
runs-on: ubuntu-latest
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run production deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test:
needs: analyze
runs-on: ubuntu-latest
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run test deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

View File

@@ -0,0 +1,11 @@
name: Require Package Version Change (disabled)
on:
workflow_dispatch: {}
jobs:
noop:
runs-on: ubuntu-latest
steps:
- name: Disabled
run: echo "version-gate workflow disabled — gating is handled in code-analysis.yml"

28
.github/workflows/android-ci.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper/
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- name: Build
run: |
./gradlew :app:assembleDebug

12
.gitignore vendored
View File

@@ -88,6 +88,12 @@ out
.nuxt
dist
# Android / Gradle generated and machine-local files
/android-app/.gradle/
/android-app/.kotlin/
/android-app/**/build/
/android-app/local.properties
# Build output (but keep production data!)
.output
!.output/.gitkeep
@@ -143,6 +149,8 @@ Thumbs.db
# Temporary files
*.tmp
*.temp
temp/webpage-downloads/data/
temp/webpage-downloads/*.html
# Security tooling artifacts (CI downloads)
gitleaks
@@ -154,3 +162,7 @@ server/data/**
!server/data/.gitkeep
public/data/**
public/uploads/**
backups/*
public/data
server/data
public/uploads

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

36
ANDROID_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,36 @@
Android Architektur (Kotlin + Jetpack Compose) — Vorschlag
Packages/Module Struktur:
- app/ (Android-App module)
- src/main/java/de/harheimertc/
- ui/
- navigation/ (NavGraph, Routes)
- screens/ (HomeScreen, TermineScreen, SpielplanScreen, GalleryScreen, ContactScreen, AuthScreens, CMS Screens)
- components/ (TopBar, BottomNav, Cards, Modals)
- theme/ (Color.kt, Typography.kt, Theme.kt)
- data/
- api/ (Retrofit interfaces, DTOs)
- repository/ (Repositories für Domain-Modelle)
- local/ (Room DAOs, Entities)
- di/ (Hilt Modules)
- domain/ (UseCases, Business-Logic)
- util/ (Extensions, DateUtils, ImageUtils)
- auth/ (AuthManager, Passkeys helper)
Wichtige Dateien:
- `MainActivity.kt` — Hosts Compose NavHost
- `AppTheme.kt` — Compose Material3 Theme mit Token-Mapping
- `NetworkModule` (Hilt) — Retrofit + OkHttp + Auth Interceptor
- `Repository` Layer — entkoppelt UI von Netz
- `Room` Entities — für Caching von Termine/News/Galerie
Auth-Strategie:
- AuthRepository verwaltet Login/Logout, `checkAuth()` (mirroring `/api/auth/status`).
- Token/Cookie-Speicherung: `EncryptedSharedPreferences` für Tokens oder `CookieJar` mit OKHttp-Client.
- Passkeys: `Fido2Client` wrapper + Bridge zu Server-API (Formate prüfen).
Build / Module Tipps:
- Start mit Single Module `app/` und später evtl. `:data`, `:domain` Trennung.
- Verwende Gradle Kotlin DSL (build.gradle.kts).
Diese Architekturdatei wurde generiert; ich kann nun ein initiales Gradle-Kotlin-Scaffold erzeugen. Soll ich das direkt in `android-app/` ablegen? (Ja/Nein)

197
ANDROID_KOTLIN_PLAN.md Normal file
View File

@@ -0,0 +1,197 @@
Android App — Kotlin (Jetpack Compose) Plan und Abhakliste
Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web-UI 1:1 abbildet (Farben, Typografie, Funktionalitäten). Diese Datei enthält Architekturentscheidungen, empfohlene Bibliotheken und eine detaillierte Abhakliste (schrittweise).
1) Zusammenfassung der Entscheidungen
- Plattform: Native Android
- Sprache: Kotlin
- UI-Toolkit: Jetpack Compose (Compose Material3)
- Architektur: MVVM mit `ViewModel` + Kotlin Coroutines + Flow
- DI: Hilt
- HTTP-Client: Ktor Client oder Retrofit + OkHttp (empfohlen: Retrofit für breite Community-Docs)
- Bild-Loading: Coil
- Lokale DB / Caching: Room + DataStore (Preferences)
- Background/Sync: WorkManager
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
2) Design & Farben
- Material Theme (Material3) mit Farben aus `tailwind.config.js` (Primary + Accent).
- Fonts: Inter & Montserrat via Google Fonts (Download/Bundle oder Play-Services-Download at runtime).
- Mapping: Tailwind-Token → `colors.xml` / Compose `Color` tokens.
3) Empfohlene Abhängigkeiten (erste Implementierung)
- androidx.compose.* (ui, material3, navigation)
- androidx.lifecycle:lifecycle-viewmodel-ktx
- com.google.dagger:hilt-android
- retrofit2 + converter-moshi / kotlinx-serialization
- io.coil-kt:coil-compose
- androidx.room:room-runtime + room-ktx
- androidx.work:work-runtime-ktx
- androidx.datastore:datastore-preferences
- com.google.android.gms:play-services-auth (für passkeys falls nötig)
- io.sentry:sentry-android (optional)
4) Detaillierte Abhakliste (Schritte)
[x] 1. Repo-Analyse: Liste der externen Endpunkte und Auth-Anforderungen exportieren
[x] 2. Projekt-Scaffold: Android Studio Projekt mit Kotlin + Compose anlegen
[x] 3. App-Architektur: Module / Packages anlegen (ui, data, domain, di, util)
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
[ ] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
[x] 7d. Tablet-Navigation: öffentliche Haupt- und Subnavigation der Web-UI mit Portierungszielen abbilden
[x] 7e. Navigation: dynamische Mannschaftslinks, Galerie-Sichtbarkeit und rollenabhängiges `Intern` wie in der Web-UI anbinden
[x] 8. Start-Screen: `HomeScreen` webnah mit Hero, Navigation, Termine, Spielen, News und Aktionen umsetzen
[x] 9. Komponenten: NavBar, Footer, Cards, ImageGrid und News-Dialog implementieren
[x] 10. Öffentliche Screens aus der Web-Navigation portieren
- [x] `/` Startseite
- [x] `/termine`: öffentliche Terminliste mit Lade-, Leer- und Fehlerzustand
- [x] `/mannschaften/spielplaene`: Saison-, Wettbewerbs- und Mannschaftsfilter mit Spielkarten
- [x] `/verein/galerie`: Anzeige-Screen vorhanden
- [x] `/kontakt`: Formular-Screen vorhanden
- [x] `/mitgliedschaft`: Antrag, Validierung, PDF-Erzeugung und PDF-Öffnen
- [x] `/verein/ueber-uns`: CMS-Inhalt aus der öffentlichen Konfiguration
- [x] `/vorstand`: öffentliche Vorstandsangaben aus der Konfiguration
- [x] `/verein/geschichte`: CMS-Inhalt aus der öffentlichen Konfiguration
- [x] `/verein/satzung`: CMS-Inhalt und PDF-Aufruf aus der öffentlichen Konfiguration
- [x] `/vereinsmeisterschaften`: Ergebnisliste mit Jahresfilter und Statistik
- [x] `/links`: strukturierte CMS-Links mit Fallback-Verweisen
- [x] `/mannschaften`: Übersicht aus saisonaler Mannschafts-CSV
- [x] `/mannschaften/[slug]`: dynamische Mannschaftsdetails mit aktuellem Spielplan und Umschaltung `Matches`/`Tabelle`
- [x] `/spielsysteme`: Spielsystemkarten mit Kategoriefilter aus CSV
- [x] `/training`: Trainingsort und gruppierte Trainingszeiten aus der Konfiguration
- [x] `/training/trainer`
- [x] `/training/anfaenger`
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
[ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [ ] `/anlagen`
- [ ] `/impressum`
- [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
[ ] 10b. Newsletter-Screens portieren
- [ ] `/newsletter/subscribe`
- [ ] `/newsletter/unsubscribe`
- [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10c. Auth-Screens portieren
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
- [x] `/registrieren`
- [x] `/passwort-vergessen`
[ ] 10d. Mitgliederbereich portieren
- [ ] `/mitgliederbereich`: Übersicht
- [ ] `/mitgliederbereich/mitglieder`
- [ ] `/mitgliederbereich/news`
- [ ] `/mitgliederbereich/profil`
- [ ] `/mitgliederbereich/api`
[ ] 10e. CMS-Screens nach Rollenberechtigung portieren
- [ ] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
- [ ] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
- [ ] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
[ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
- [x] Login/Logout und verschlüsselte Token-Speicherung
- [x] Registrierung und Passwort-Reset
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
[ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
[ ] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
[ ] 23. Crash-Reporting: Sentry / Crashlytics integrieren
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
5) Kurzzeit-MVP (Priorität für erste Version)
- [x] A. Auth (Login/Logout)
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
- [x] C. Kontaktformular (absenden)
- [ ] D. Bildanzeige + Caching
- [x] E. Theme & Fonts
6) Nächste Aktionen (sofort)
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
- Passkey-Anmeldung über Android Credential Manager anbinden.
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
- Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der verwendeten Backend-Endpunkte ergänzen.
7) Umsetzungsprotokoll
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
- 2026-05-27: `Termine` und `Spielplan` als native Screens umgesetzt; Spielplan unterstützt Saison, Wettbewerb, Mannschaft, Ergebnis und zweizeilige Gruppeninformation.
- 2026-05-27: `Mitgliedschaft` mit Antrag/PDF-Abruf sowie Passwort-`Login`/`Logout` umgesetzt; offene Auth-Härtung separat ausgewiesen.
- 2026-05-27: Tokens verschlüsselt persistiert; Session-Wiederherstellung sowie Logout per Bearer-Token in den Auth-Endpunkten ermöglicht.
- 2026-05-27: Registrierung und Passwort-Reset an die vorhandenen Auth-Endpunkte angebunden.
- 2026-05-27: Product-Flavors `local`, `instantTest` und `production` eingerichtet; lokale Basis-URL ist per Gradle-Parameter überschreibbar.
- 2026-05-27: Gradle-Heap/Worker für Flavor-Builds festgelegt, nachdem paralleles D8/KSP mit dem 512-MiB-Standardheap nicht ausreichend Speicher hatte.
- 2026-05-27: Lokales Testsetup gegen Emulator geprüft; bei IPv6-gebundenem Nuxt-Dev-Server wird die von Nuxt ausgegebene Network-URL per `LOCAL_API_BASE_URL` verwendet.
- 2026-05-27: Adaptive Navigation umgesetzt; Tablet-Layouts ab `600dp` zeigen Header und Hauptmenue dauerhaft, Smartphone-Layouts behalten die vorhandene Navigation.
- 2026-05-27: Platzhalterlogo in der Android-Navigation durch das vorhandene Harheimer-TC-Weblogo als skalierte lokale PNG-Ressource ersetzt.
- 2026-05-27: Web-Navigation und `pages/` vollständig inventarisiert; Tablet-Haupt-/Subnavigation für die öffentlichen Bereiche strukturell angeglichen und alle fehlenden Screens einzeln in die Portierungsliste aufgenommen.
- 2026-05-27: Tablet-Header auf Web-Verhalten angepasst (Bereichswechsel öffnet Startseite und Submenü) und die native ActionBar zugunsten des App-Headers entfernt.
- 2026-05-27: Navigation mit Live-Mannschaftslinks, öffentlicher Galerie-Sichtbarkeit und rollenabhängigem `Intern` ergänzt; Mannschaftsübersicht/-detail sowie Training, Trainer und Anfänger nativ portiert.
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
- Lokal, wenn `10.0.2.2` nicht erreichbar ist: `./gradlew :app:installLocalDebug -PLOCAL_API_BASE_URL=http://<NUXT-NETWORK-HOST>:3100/`; die passende URL steht in der `npm run dev`-Ausgabe (hier `http://torstens:3100/`).
- Test-Instanz: `./gradlew :app:installInstantTestDebug` verwendet `https://harheimertc.tsschulz.de/` und die App-ID `de.harheimertc.test`.
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
9) Dauerhaftes Android-Login: Architektur und Umsetzung
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
- Token-Modell:
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
- Backend-Arbeitspaket:
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
- Android-Arbeitspaket:
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
- Sicherheitsregeln:
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
---
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)

84
ANDROID_PORT_TODO.md Normal file
View File

@@ -0,0 +1,84 @@
ANDROID App - 1:1 Portierung der Web-UI (TODO)
Ziel: Die Web-UI des Projekts 1:1 in eine native (oder cross-platform) Android-App überführen, inklusive Farben, Designsystem und aller Funktionalitäten.
1) Analyse Codebasis & Assets
- Analysiere `package.json`, `nuxt.config.js`, `tailwind.config.js` und zentrale Server-/API-Endpunkte.
- Liste alle verwendeten Farben, CSS-Variablen, Tailwind-Konfigurationen.
- Sammle alle statischen Assets: Bilder, Icons, SVGs, Fonts, PDF-Dokumente.
- Identifiziere dynamische Komponenten: Formulare, Rich-Text-Editor, Uploads, Kalender, Navigation.
2) Projektziele und Scope
- Entscheide: Native Android (Kotlin/Jetpack Compose) oder Cross-Platform (React Native, Flutter, Kotlin Multiplatform).
- Priorisiere Features für MVP vs. Post-Launch.
3) Designsystem und Farben extrahieren
- Extrahiere Farbpalette, Typografie, Abstände, Buttons, Karten, Form-Controls.
- Erstelle eine Design-Token-Liste (Hex/RGBA, Namen, Einsatzbereiche).
4) Technologie-Stack wählen
- Empfohlene Optionen: Kotlin + Jetpack Compose (native), Flutter (UI-First), React Native (Wiederverwendung von JS/nuxt-Logik).
- Bibliotheken: Navigation, HTTP-Client, Bild-Handling, Auth (WebAuthn falls nötig), Local DB.
5) Android-Projekt aufsetzen
- Erstelle Projekt-Scaffold, CI-Build, Signing-Config.
6) Theme & Farben implementieren
- Implementiere App-Theme mit Farben/Typografie-Token.
7) Navigation-Struktur implementieren
- Bottom/Navigations-Drawer/Stack wie Web-Navigation abbilden.
8) Screens für Seiten anlegen
- Erstelle Screens für: Startseite, Termine, Spielplan, Galerie, Kontakt, News, Mitgliedschaft, Login, CMS-Bereiche.
9) UI-Komponenten portieren
- Navbar, Footer, Cards, Image-Grid, Modal/Dialog, Rich-Text-Viewer/Editor, Date-Picker, Tabellen.
10) Formulare & Validierung implementieren
- Registrieren, Login, Passwort vergessen, Mitgliedschaftsformulare mit Client- und Server-Validierung.
11) Authentifizierungs-Flow implementieren
- JWT / Session, OAuth oder WebAuthn falls benötigt; Token-Handling sicher speichern.
12) API-Client implementieren
- Einheitlicher HTTP-Client, Error-Handling, Retry-Strategien, Pagination.
13) Bilderupload & Storage einrichten
- Multi-part Upload, Progress, Bildkompression, lokale Cache-Strategie.
14) Offline-Support und Caching
- Caching von API-Responses, Bild-Caching, Sync-Strategie für Formulare.
15) Lokalisierung und Texte prüfen
- Alle statischen Texte extrahieren, deutsche Strings prüfen und in Resource-Files ablegen.
16) Accessibility-Prüfung und Anpassungen
- Farbkontrast, Touch-Targets, Screenreader-Labels.
17) Unit- und UI-Tests schreiben
- Komponenten- und Integrations-Tests, E2E (falls möglich).
18) Performance-Optimierung durchführen
- Bilder, Netzwerk, Render-Perf.
19) CI/CD für Builds einrichten
- GitHub Actions / GitLab CI: Build, Test, Lint, Release.
20) Play Store Release vorbereiten
- App-Icons, Screenshots, Privacy-Policy, Datensparsamkeit.
21) Monitoring & Crash-Reporting einrichten
- Sentry / Firebase Crashlytics, Analytics.
22) Dokumentation: Setup & Architektur
- README, Architekturdiagramm, API-Spec, Onboarding-Guide.
23) Design Review und Abnahme
- UX/Design-Review mit Stakeholdern.
24) Launch und Feedbackrunde durchführen
- Release-Notes, Feedback-Formular, Bug-Fixing-Plan.
Datei erstellt: Bitte bestätige, wenn ich mit der in-depth Analyse der Codebasis und Assets beginnen soll (Suche nach Farben, verwendeten Komponenten, Images, Fonts, relevanten Scripts).

58
ANDROID_REPO_ENDPOINTS.md Normal file
View File

@@ -0,0 +1,58 @@
Repo API Endpoints — Übersicht
Hinweis: Viele Frontend-Requests verwenden relative Pfade (`/api/...`) und Nuxt's `NUXT_PUBLIC_BASE_URL`.
Öffentliche/Frontend-Endpunkte (häufig genutzt):
- GET /api/config
- GET /api/news-public
- GET /api/news
- GET /api/termine
- GET /api/spielplaene
- GET /api/spielplan
- GET /api/mannschaften
- GET /api/galerie
- GET /api/media/galerie/{id}
- GET /api/personen/{filename}?width=...&height=...
- POST /api/contact
- POST /api/news (CMS)
Galerie / Media:
- POST /api/galerie/upload
- GET /api/galerie/list
- GET /api/galerie/[id]
- DELETE /api/galerie/[id]
Authentifizierung:
- POST /api/auth/login
- POST /api/auth/logout
- POST /api/auth/register
- POST /api/auth/reset-password
- GET /api/auth/status
- POST /api/auth/passkeys/authentication-options (Passkeys start: server returns WebAuthn options)
- POST /api/auth/passkeys/login (Passkeys finish: credential verification)
CMS / geschützte Endpunkte (erfordern Auth):
- GET /api/cms/* (z.B. /api/cms/users/list, /api/cms/contact-requests)
- POST /api/cms/save-csv
- POST /api/cms/upload-spielplan-pdf
- POST /api/cms/satzung-upload
- POST /api/members, DELETE /api/members, POST /api/members/bulk
- POST /api/membership/update-status
- POST /api/termine-manage, DELETE /api/termine-manage, GET /api/termine-manage
Weitere (Datei-Uploads, Personen):
- POST /api/personen/upload
- GET /api/app/version
- Various CMS-specific routes under /api/cms
Auth-Anforderungen & Hinweise:
- Frontend nutzt `$fetch('/api/...')` (Nuxt) — serverseitig vermutlich Session-Cookie oder JWT.
- `stores/auth.js` verwendet `/api/auth/status` to check login state and `passkeyLogin()` which calls `/api/auth/passkeys/*`.
- Passkeys-Flow verwendet `@simplewebauthn/browser` on web; Android port should support FIDO2 / Passkeys (Google Passkeys API) or provide password fallback.
- CMS- und Manage-Endpunkte require authentication and role checks (admin/vorstand etc.).
Empfehlung für Android-Client:
- Nutze Retrofit/OkHttp mit anpassbarem Auth-Interceptor (Cookie-jar or token storage). Prüfe, ob Server bevorzugt Cookies (then use CookieJar) or JWT Authorization header.
- Implementiere Passkeys via Android FIDO2 / Passkeys APIs as optional fast-login path; for servers expecting WebAuthn payloads adapt encoding accordingly.
Datei automatisch erzeugt — wenn du möchtest, kann ich nun alle Dateien in `public/` und `assets/` auflisten und exportieren (Bilder, Fonts, PDFs).

View File

@@ -0,0 +1,112 @@
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
}
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
.orElse("http://10.0.2.2:3100/")
.get()
android {
namespace = "de.harheimertc"
compileSdk = 34
defaultConfig {
applicationId = "de.harheimertc"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
flavorDimensions += "environment"
productFlavors {
create("local") {
dimension = "environment"
applicationIdSuffix = ".local"
versionNameSuffix = "-local"
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
create("instantTest") {
dimension = "environment"
applicationIdSuffix = ".test"
versionNameSuffix = "-test"
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
manifestPlaceholders["usesCleartextTraffic"] = "false"
}
create("production") {
dimension = "environment"
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
manifestPlaceholders["usesCleartextTraffic"] = "false"
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.6.1")
// Compose
implementation("androidx.compose.ui:ui:1.5.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.5.0")
implementation("androidx.compose.material3:material3:1.1.0")
implementation("androidx.navigation:navigation-compose:2.6.0")
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
// Hilt
implementation("com.google.dagger:hilt-android:2.59.2")
ksp("com.google.dagger:hilt-compiler:2.59.2")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
// WorkManager, DataStore
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Testing (skeleton)
testImplementation("junit:junit:4.13.2")
}

View File

@@ -0,0 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".HarheimerApplication"
android:label="HarheimerTC"
android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,7 @@
package de.harheimertc
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class HarheimerApplication : Application()

View File

@@ -0,0 +1,35 @@
package de.harheimertc
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.theme.HarheimerTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
@Composable
fun App() {
HarheimerTheme {
val navController = rememberNavController()
NavGraph(navController = navController)
}
}
@Preview
@Composable
fun PreviewMain() {
App()
}

View File

@@ -0,0 +1,39 @@
package de.harheimertc.data
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccessTokenAuthenticator @Inject constructor(
private val sessionRefresher: SessionRefresher,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
return null
}
val currentAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
?.takeIf(String::isNotBlank)
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 0
while (current != null) {
count += 1
current = current.priorResponse
}
return count
}
}

View File

@@ -0,0 +1,282 @@
package de.harheimertc.data
import com.squareup.moshi.Json
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
import retrofit2.http.Streaming
import okhttp3.ResponseBody
data class ContactRequest(val name: String, val email: String, val message: String)
data class ContactResponse(val ok: Boolean, val id: String? = null)
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TerminDto(
val datum: String = "",
val uhrzeit: String? = null,
val titel: String = "",
val beschreibung: String? = null,
val kategorie: String? = null,
)
data class SpielplanResponse(
val success: Boolean = false,
val message: String? = null,
val data: List<SpielDto> = emptyList(),
val headers: List<String> = emptyList(),
val season: String? = null,
val seasons: List<SeasonDto> = emptyList(),
)
data class SeasonDto(val slug: String = "", val label: String = "")
data class SpielDto(
@param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
@param:Json(name = "Altersklasse") val altersklasse: String = "",
@param:Json(name = "Liga") val liga: String = "",
@param:Json(name = "Staffel") val staffel: String = "",
@param:Json(name = "Runde") val runde: String? = null,
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
@param:Json(name = "SpieleGast") val spieleGast: String = "",
)
data class TeamTableResponse(
val success: Boolean = false,
val message: String? = null,
val season: String? = null,
val table: TeamTableDto? = null,
)
data class TeamTableDto(
val teamName: String = "",
val leagueName: String = "",
val table: LeagueTableDto? = null,
)
data class LeagueTableDto(
val leagueTable: List<LeagueTableRowDto> = emptyList(),
)
data class LeagueTableRowDto(
@param:Json(name = "table_rank") val rank: Int? = null,
@param:Json(name = "team_name") val teamName: String = "",
@param:Json(name = "meetings_count") val meetings: Int? = null,
@param:Json(name = "meetings_won") val won: Int? = null,
@param:Json(name = "meetings_tie") val tied: Int? = null,
@param:Json(name = "meetings_lost") val lost: Int? = null,
@param:Json(name = "sets_won") val setsWon: Int? = null,
@param:Json(name = "sets_lost") val setsLost: Int? = null,
@param:Json(name = "games_won") val gamesWon: Int? = null,
@param:Json(name = "games_lost") val gamesLost: Int? = null,
@param:Json(name = "points_won") val pointsWon: Int? = null,
@param:Json(name = "points_lost") val pointsLost: Int? = null,
@param:Json(name = "rise_fall_state") val movement: String? = null,
)
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto(
val id: Int? = null,
val title: String = "",
val content: String = "",
val created: String? = null,
)
data class PublicGalleryImageDto(
val filename: String = "",
val title: String = "",
)
data class MembershipRequest(
val vorname: String,
val nachname: String,
val strasse: String,
val plz: String,
val ort: String,
val geburtsdatum: String,
val email: String,
val telefon_privat: String? = null,
val telefon_mobil: String? = null,
val mitgliedschaftsart: String,
val lastschrift_erlaubt: Boolean,
val kontoinhaber: String,
val iban: String,
val bic: String? = null,
val bank: String? = null,
val datenschutz_einverstanden: Boolean,
val satzung_anerkannt: Boolean,
)
data class MembershipResponse(
val success: Boolean = false,
val message: String? = null,
val downloadUrl: String? = null,
)
data class LoginRequest(
val email: String,
val password: String,
val client: String = "android",
val deviceName: String = "Harheimer TC Android-App",
)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
val name: String? = null,
val roles: List<String> = emptyList(),
)
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val sessionId: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
data class RefreshRequest(val refreshToken: String)
data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
val roles: List<String> = emptyList(),
val role: String? = null,
)
data class ResetPasswordRequest(val email: String)
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
data class RegistrationVisibility(val showBirthday: Boolean)
data class RegistrationRequest(
val name: String,
val email: String,
val phone: String? = null,
val password: String,
val geburtsdatum: String,
val visibility: RegistrationVisibility,
)
data class TrainingLocationDto(
val name: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
)
data class TrainingTimeDto(
val id: String = "",
val tag: String = "",
val von: String = "",
val bis: String = "",
val gruppe: String = "",
val info: String? = null,
)
data class TrainingDto(
val ort: TrainingLocationDto = TrainingLocationDto(),
val zeiten: List<TrainingTimeDto> = emptyList(),
)
data class TrainerDto(
val id: String = "",
val name: String = "",
val lizenz: String = "",
val schwerpunkt: String = "",
val zusatz: String? = null,
val imageFilename: String? = null,
)
data class BoardMemberDto(
val vorname: String = "",
val nachname: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val telefon: String = "",
val email: String = "",
val imageFilename: String? = null,
)
data class VorstandDto(
val vorsitzender: BoardMemberDto = BoardMemberDto(),
val stellvertreter: BoardMemberDto = BoardMemberDto(),
val kassenwart: BoardMemberDto = BoardMemberDto(),
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
val sportwart: BoardMemberDto = BoardMemberDto(),
val jugendwart: BoardMemberDto = BoardMemberDto(),
)
data class SatzungDto(
val pdfUrl: String = "",
val content: String = "",
)
data class LinkItemDto(
val label: String = "",
val href: String = "",
val description: String = "",
)
data class LinkSectionDto(
val title: String = "",
val items: List<LinkItemDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
val geschichte: String = "",
val ttRegeln: String = "",
val satzung: SatzungDto = SatzungDto(),
val links: String = "",
val linksStructured: List<LinkSectionDto> = emptyList(),
)
data class ConfigResponse(
val training: TrainingDto = TrainingDto(),
val trainer: List<TrainerDto> = emptyList(),
val vorstand: VorstandDto = VorstandDto(),
val seiten: SeitenDto = SeitenDto(),
)
interface ApiService {
@POST("/api/contact")
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
@GET("/api/galerie/list")
suspend fun galerieList(): Response<List<String>>
@GET("/api/galerie")
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
@GET("/api/termine")
suspend fun termine(): Response<TermineResponse>
@GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@GET("/api/spielplan/table")
suspend fun spielplanTable(
@Query("team") team: String,
@Query("season") season: String? = null,
): Response<TeamTableResponse>
@GET("/api/news-public")
suspend fun publicNews(): Response<NewsPublicResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@GET("/data/spielsysteme.csv")
suspend fun spielsysteme(): Response<ResponseBody>
@GET("/api/vereinsmeisterschaften")
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
@POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@Streaming
@GET
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
@POST("/api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout")
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
@POST("/api/auth/refresh")
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
@GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse>
@POST("/api/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
@POST("/api/auth/register")
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
}

View File

@@ -0,0 +1,17 @@
package de.harheimertc.data
import de.harheimertc.repositories.AuthRepository
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder()
val token = authRepository.getToken()
if (!token.isNullOrBlank()) {
requestBuilder.addHeader("Authorization", "Bearer $token")
}
return chain.proceed(requestBuilder.build())
}
}

View File

@@ -0,0 +1,57 @@
package de.harheimertc.data
import de.harheimertc.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.JavaNetCookieJar
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
import java.net.CookieManager
import java.net.CookiePolicy
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
return OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor)
.authenticator(accessTokenAuthenticator)
.addInterceptor(logging)
.build()
}
@Provides
@Singleton
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
}

View File

@@ -0,0 +1,64 @@
package de.harheimertc.data
import com.squareup.moshi.Moshi
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SessionRefresher @Inject constructor(
private val authRepository: AuthRepository,
moshi: Moshi,
) {
private val lock = Any()
private val client = OkHttpClient.Builder().build()
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
refreshAccessTokenBlocking() != null
}
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
val currentToken = authRepository.getToken()
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
return@synchronized currentToken
}
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
?: return@synchronized null
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
val request = Request.Builder()
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
authRepository.clearSession()
return@synchronized null
}
if (!response.isSuccessful) return@synchronized null
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
?: return@synchronized null
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
?: return@synchronized null
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized null
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
accessToken
}
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,17 @@
package de.harheimertc.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.AuthRepositoryImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}

View File

@@ -0,0 +1,9 @@
package de.harheimertc.repositories
interface AuthRepository {
fun getToken(): String?
fun getRefreshToken(): String?
fun getSessionId(): String?
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
fun clearSession()
}

View File

@@ -0,0 +1,49 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id"
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun getToken(): String? = preferences.getString(tokenKey, null)
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
preferences.edit().apply {
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
}.apply()
}
override fun clearSession() {
preferences.edit()
.remove(tokenKey)
.remove(refreshTokenKey)
.remove(sessionIdKey)
.apply()
}
}

View File

@@ -0,0 +1,13 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactResponse
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContactRepository @Inject constructor(private val api: ApiService) {
suspend fun sendContact(req: ContactRequest): Response<ContactResponse> = api.postContact(req)
}

View File

@@ -0,0 +1,27 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GalleryRepository @Inject constructor(private val api: ApiService) {
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
val response = api.publicGalleryImages()
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body().orEmpty().isNotEmpty()
}
suspend fun fetchImages(): Result<List<String>> {
return try {
val resp = api.galerieList()
if (resp.isSuccessful) {
Result.success(resp.body() ?: emptyList())
} else {
Result.failure(Exception("HTTP ${resp.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,24 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import javax.inject.Inject
import javax.inject.Singleton
data class HomeData(
val termine: List<TerminDto>,
val spiele: List<SpielDto>,
val news: List<NewsDto>,
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty()
val spiele = api.spielplan().body()?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
HomeData(termine, spiele, news)
}
}

View File

@@ -0,0 +1,67 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
suspend fun logout(): Result<Unit> = runCatching {
try {
api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally {
authRepository.clearSession()
}
}
suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
return@runCatching AuthStatusResponse()
}
var response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse()
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort")
}
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
}

View File

@@ -0,0 +1,77 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
data class Mannschaft(
val mannschaft: String,
val liga: String,
val staffelleiter: String,
val telefon: String,
val heimspieltag: String,
val spielsystem: String,
val mannschaftsfuehrer: String,
val spieler: List<String>,
val informationenLink: String,
val letzteAktualisierung: String,
) {
val slug: String
get() = mannschaft.lowercase(Locale.GERMANY).replace(Regex("\\s+"), "-")
}
@Singleton
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty())
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
.filter(String::isNotBlank)
.drop(1)
.mapNotNull { row ->
val fields = parseCsvRow(row)
if (fields.size < 10 || fields[0].isBlank()) return@mapNotNull null
Mannschaft(
mannschaft = fields[0],
liga = fields[1],
staffelleiter = fields[2],
telefon = fields[3],
heimspieltag = fields[4],
spielsystem = fields[5],
mannschaftsfuehrer = fields[6],
spieler = fields[7].split(';').map(String::trim).filter(String::isNotBlank),
informationenLink = fields[8],
letzteAktualisierung = fields[9],
)
}
.toList()
private fun parseCsvRow(row: String): List<String> {
val values = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
var index = 0
while (index < row.length) {
val character = row[index]
when {
character == '"' && inQuotes && row.getOrNull(index + 1) == '"' -> {
current.append('"')
index++
}
character == '"' -> inQuotes = !inQuotes
character == ',' && !inQuotes -> {
values += current.toString().trim()
current.clear()
}
else -> current.append(character)
}
index++
}
values += current.toString().trim()
return values
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.ApiService
import de.harheimertc.data.MembershipRequest
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class MembershipDocument(val message: String, val uri: String)
@Singleton
class MembershipRepository @Inject constructor(
private val api: ApiService,
@param:ApplicationContext private val context: Context,
) {
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
val response = api.generateMembershipPdf(request)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
val documentResponse = api.downloadMembershipPdf(downloadUrl)
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
val file = File(directory, "beitrittserklaerung.pdf")
documentResponse.body()?.byteStream()?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: error("Leere PDF-Antwort")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
MembershipDocument(
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
uri = uri.toString(),
)
}
}

View File

@@ -0,0 +1,161 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.LinkItemDto
import de.harheimertc.data.LinkSectionDto
import javax.inject.Inject
import javax.inject.Singleton
data class Spielsystem(
val name: String,
val description: String,
val teamSize: String,
val category: String,
val sequence: String,
val gameCount: String,
val features: String,
)
data class MeisterschaftResult(
val year: String,
val category: String,
val rank: String,
val playerOne: String,
val playerTwo: String,
val note: String,
val imageOne: String,
val imageTwo: String,
)
@Singleton
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
val response = api.config()
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
val response = api.spielsysteme()
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 8) return@mapNotNull null
Spielsystem(
name = values[0],
description = values[1],
teamSize = values[2],
category = values[3],
sequence = values[5],
gameCount = values[6],
features = values[7],
)
}
}
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 6) return@mapNotNull null
MeisterschaftResult(
year = values[0],
category = values[1],
rank = values[2],
playerOne = values[3],
playerTwo = values[4],
note = values[5],
imageOne = values.getOrElse(6) { "" },
imageTwo = values.getOrElse(7) { "" },
)
}
}
}
private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
private fun parseCsvLine(line: String): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
',' -> if (quoted) value.append(char) else {
values += value.toString().trim()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString().trim()
return values
}
fun ConfigResponse.linkSections(): List<LinkSectionDto> =
seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() }
.ifEmpty {
parseLinkSections(seiten.links).ifEmpty { defaultLinkSections }
}
private fun parseLinkSections(html: String): List<LinkSectionDto> {
if (html.isBlank()) return emptyList()
val sectionRegex = Regex("""<h2[^>]*>(.*?)</h2>(.*?)(?=<h2[^>]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
val itemRegex = Regex("""<li[^>]*>(.*?)</li>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
val anchorRegex = Regex("""<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
return sectionRegex.findAll(html).mapNotNull { section ->
val title = stripHtml(section.groupValues[1])
val items = itemRegex.findAll(section.groupValues[2]).mapNotNull { item ->
val match = anchorRegex.find(item.groupValues[1]) ?: return@mapNotNull null
LinkItemDto(
href = match.groupValues[1].trim(),
label = stripHtml(match.groupValues[2]),
description = stripHtml(item.groupValues[1].replace(match.value, "")),
)
}.toList()
title.takeIf { it.isNotBlank() && items.isNotEmpty() }?.let { LinkSectionDto(it, items) }
}.toList()
}
private fun stripHtml(html: String): String = html
.replace(Regex("<[^>]*>"), "")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
.replace(Regex("\\s+"), " ")
.trim()
private val defaultLinkSections = listOf(
LinkSectionDto("Ergebnisse & Portale", listOf(
LinkItemDto("MyTischtennis.de", "http://www.mytischtennis.de/public/home", "(offizielle QTTR-Werte)"),
LinkItemDto("Click-tt Ergebnisse", "http://httv.click-tt.de/", "(offizieller Ergebnisdienst HTTV)"),
LinkItemDto("Tischtennis Pur", "https://www.tischtennis-pur.de/", "(Informationen, Blogs und Tipps)"),
LinkItemDto("Liveticker 2. und 3. TT-Bundesliga", "https://ticker.tt-news.com/"),
)),
LinkSectionDto("Verbände", listOf(
LinkItemDto("Hessischer Tischtennisverband (HTTV)", "http://www.httv.de/"),
LinkItemDto("Deutscher Tischtennisbund (DTTB)", "http://www.tischtennis.de/aktuelles/"),
LinkItemDto("European Table Tennis Union (ETTU)", "http://www.ettu.org/"),
LinkItemDto("International Table Tennis Federation (ITTF)", "https://www.ittf.com/"),
)),
LinkSectionDto("Regionale Links", listOf(
LinkItemDto("Stadt Frankfurt", "http://www.frankfurt.de/"),
LinkItemDto("Vereinsring Harheim", "http://www.harheim.com/"),
)),
LinkSectionDto("Partner & Vereine", listOf(
LinkItemDto("TTC OE Bad Homburg", "http://www.ttcoe.de/"),
LinkItemDto("SpVgg Steinkirchen e.V.", "https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis"),
LinkItemDto("Ergebnisse SpVgg Steinkirchen", "https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/"),
)),
)

View File

@@ -0,0 +1,26 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SpielplanRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body
}
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
val response = api.spielplanTable(team, season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
body
}
}

View File

@@ -0,0 +1,15 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.TerminDto
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TermineRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
val response = api.termine()
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.termine.orEmpty()
}
}

View File

@@ -0,0 +1,15 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ConfigResponse
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrainingRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
val response = api.config()
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
}

View File

@@ -0,0 +1,294 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.harheimertc.BuildConfig
import de.harheimertc.R
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
private enum class MenuSection {
VEREIN,
MANNSCHAFTEN,
TRAINING,
NEWSLETTER,
INTERN,
}
private data class MenuTarget(val label: String, val route: String)
@Composable
fun AppNavigationHeader(
selectedRoute: String?,
onNavigate: (String) -> Unit,
webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Brush.horizontalGradient(listOf(Accent900, Primary900, Accent900)))
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
} else {
CompactNavigation(selectedRoute, onNavigate)
}
}
}
@Composable
private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) {
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
}
}
@Composable
private fun WebTabletNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
navigationState: NavigationUiState,
) {
val section = menuSection(selectedRoute)
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.width(16.dp))
Row(
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) })
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
}
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
}
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) })
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) }
}
}
val subItems = submenu(section, navigationState)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) }
}
}
}
@Composable
private fun BrandRow(onLogin: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.weight(1f))
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
}
}
@Composable
private fun Brand() {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.harheimer_tc_logo),
contentDescription = "Harheimer TC Logo",
modifier = Modifier.size(42.dp),
)
Spacer(Modifier.width(10.dp))
Text("Harheimer ", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text("TC", color = Color(0xFFF87171), style = MaterialTheme.typography.titleLarge)
if (BuildConfig.ENVIRONMENT_NAME.isNotBlank()) {
Text(
BuildConfig.ENVIRONMENT_NAME,
color = Color.White,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.padding(start = 8.dp)
.background(Primary600, RoundedCornerShape(5.dp))
.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
}
@Composable
private fun MainLink(
label: String,
selected: Boolean,
primary: Boolean = false,
onClick: () -> Unit,
) {
Surface(
color = if (selected || primary) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
maxLines = 1,
)
}
}
@Composable
private fun CompactLink(
label: String,
route: String,
selectedRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) },
) {
Text(
label,
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Primary600 else Color.Transparent,
shape = RoundedCornerShape(5.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = if (selected) Color.White else Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
maxLines = 1,
)
}
}
private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.VereinAbout.route,
Destinations.Vorstand.route,
Destinations.Geschichte.route,
Destinations.Satzung.route,
Destinations.Vereinsmeisterschaften.route,
Destinations.Links.route,
Destinations.Gallery.route -> MenuSection.VEREIN
Destinations.Mannschaften.route,
Destinations.Spielplan.route,
Destinations.Spielsysteme.route -> MenuSection.MANNSCHAFTEN
Destinations.Training.route,
Destinations.Trainer.route,
Destinations.Anfaenger.route,
Destinations.Regeln.route -> MenuSection.TRAINING
Destinations.NewsletterSubscribe.route,
Destinations.NewsletterUnsubscribe.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
Destinations.CmsNewsletter.route,
Destinations.CmsContactRequests.route,
Destinations.Cms.route -> MenuSection.INTERN
else -> null
}.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
}
private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuTarget> = when (section) {
MenuSection.VEREIN -> listOf(
MenuTarget("Über uns", Destinations.VereinAbout.route),
MenuTarget("Vorstand", Destinations.Vorstand.route),
MenuTarget("Geschichte", Destinations.Geschichte.route),
MenuTarget("Satzung", Destinations.Satzung.route),
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
MenuTarget("Galerie", Destinations.Gallery.route),
MenuTarget("Links", Destinations.Links.route),
)
MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route),
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
MenuTarget("Spielpläne", Destinations.Spielplan.route),
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
)
MenuSection.TRAINING -> listOf(
MenuTarget("Trainingszeiten", Destinations.Training.route),
MenuTarget("Trainer", Destinations.Trainer.route),
MenuTarget("Anfänger", Destinations.Anfaenger.route),
MenuTarget("TT-Regeln", Destinations.Regeln.route),
)
MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
)
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("CMS", Destinations.Cms.route))
}
null -> emptyList()
}

View File

@@ -0,0 +1,81 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun HeroComponent(
imageUrl: String,
title: String,
subtitle: String,
ctaText: String,
onPrimaryCta: () -> Unit,
heightDp: Int = 280
) {
Box(modifier = Modifier
.fillMaxWidth()
.height(heightDp.dp)) {
AsyncImage(
model = imageUrl,
contentDescription = "Hero Image",
modifier = Modifier
.fillMaxWidth()
.height(heightDp.dp),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(Color(0x66000000), Color(0x00000000), Color(0x88000000))
)
)
)
Box(modifier = Modifier
.matchParentSize()
.padding(20.dp)) {
Column(modifier = Modifier.align(Alignment.CenterStart)) {
Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = Color.White)
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.primary
) {
Button(
onClick = onPrimaryCta,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
modifier = Modifier
) {
Text(ctaText, color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 12.dp))
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
val selected = remember { mutableStateOf<String?>(null) }
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
items(images) { img ->
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
AsyncImage(
model = img,
contentDescription = "Gallery image",
modifier = Modifier
.aspectRatio(1f)
.clickable { selected.value = img },
contentScale = ContentScale.Crop
)
}
}
}
if (selected.value != null) {
Dialog(onDismissRequest = { selected.value = null }) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.Center) {
AsyncImage(model = selected.value, contentDescription = "Full image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
Button(onClick = { selected.value = null }, modifier = Modifier.align(Alignment.TopEnd), colors = ButtonDefaults.buttonColors()) {
Text("Schließen")
}
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun PendingPage(
navController: NavController,
title: String,
webPath: String,
showBackNavigation: Boolean,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Column(
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
Text("Webseite: $webPath", color = Accent500)
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text(
"Die native Android-Seite wird in einem der nächsten Portierungsschritte umgesetzt.",
color = Primary900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(18.dp),
)
}
}
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.ui.navigation
sealed class Destinations(val route: String) {
object Home : Destinations("home")
object VereinAbout : Destinations("verein/about")
object Vorstand : Destinations("verein/vorstand")
object Geschichte : Destinations("verein/geschichte")
object Satzung : Destinations("verein/satzung")
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
object Links : Destinations("verein/links")
object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") {
fun create(slug: String): String = "mannschaften/$slug"
}
object Termine : Destinations("termine")
object Spielplan : Destinations("spielplan")
object Spielsysteme : Destinations("mannschaften/spielsysteme")
object Training : Destinations("training")
object Trainer : Destinations("training/trainer")
object Anfaenger : Destinations("training/anfaenger")
object Regeln : Destinations("training/regeln")
object Gallery : Destinations("gallery")
object NewsletterSubscribe : Destinations("newsletter/subscribe")
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
object Contact : Destinations("contact")
object Membership : Destinations("membership")
object Login : Destinations("login")
object PasswordReset : Destinations("passwordReset")
object Register : Destinations("register")
object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder")
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object MemberApi : Destinations("intern/api")
object CmsNewsletter : Destinations("cms/newsletter")
object CmsContactRequests : Destinations("cms/kontaktanfragen")
object Cms : Destinations("cms")
}

View File

@@ -0,0 +1,223 @@
package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.PendingPage
@Composable
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.Home.route,
navigationViewModel: NavigationViewModel = hiltViewModel(),
) {
val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
} else route
val navigationState by navigationViewModel.state.collectAsState()
LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession()
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) {
if (persistentNavigation) {
AppNavigationHeader(
selectedRoute = currentRoute,
onNavigate = navController::navigateTopLevel,
webTabletNavigation = true,
navigationState = navigationState,
)
}
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.weight(1f),
) {
composable(Destinations.Home.route) {
de.harheimertc.ui.screens.home.HomeScreen(
navController = navController,
showNavigationHeader = !persistentNavigation,
)
}
composable(Destinations.VereinAbout.route) {
de.harheimertc.ui.screens.publicpages.AboutScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Vorstand.route) {
de.harheimertc.ui.screens.publicpages.VorstandScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Geschichte.route) {
de.harheimertc.ui.screens.publicpages.GeschichteScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Satzung.route) {
de.harheimertc.ui.screens.publicpages.SatzungScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Vereinsmeisterschaften.route) {
de.harheimertc.ui.screens.publicpages.VereinsmeisterschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Links.route) {
de.harheimertc.ui.screens.publicpages.LinksScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Mannschaften.route) {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MannschaftDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Termine.route) {
de.harheimertc.ui.screens.termine.TermineScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Spielplan.route) {
de.harheimertc.ui.screens.spielplan.SpielplanScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Spielsysteme.route) {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Training.route) {
de.harheimertc.ui.screens.training.TrainingScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Trainer.route) {
de.harheimertc.ui.screens.training.TrainerScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Anfaenger.route) {
de.harheimertc.ui.screens.training.AnfaengerScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Regeln.route) {
de.harheimertc.ui.screens.publicpages.RegelnScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Gallery.route) {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable(Destinations.NewsletterSubscribe.route) {
PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation)
}
composable(Destinations.NewsletterUnsubscribe.route) {
PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation)
}
composable(Destinations.Contact.route) {
de.harheimertc.ui.screens.contact.ContactScreen()
}
composable(Destinations.Membership.route) {
de.harheimertc.ui.screens.membership.MembershipScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Login.route) {
de.harheimertc.ui.screens.login.LoginScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.PasswordReset.route) {
de.harheimertc.ui.screens.login.PasswordResetScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Register.route) {
de.harheimertc.ui.screens.login.RegisterScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberArea.route) {
PendingPage(navController, "Intern", "/mitgliederbereich", !persistentNavigation)
}
composable(Destinations.Members.route) {
PendingPage(navController, "Mitgliederliste", "/mitgliederbereich/mitglieder", !persistentNavigation)
}
composable(Destinations.MemberNews.route) {
PendingPage(navController, "News", "/mitgliederbereich/news", !persistentNavigation)
}
composable(Destinations.Profile.route) {
PendingPage(navController, "Mein Profil", "/mitgliederbereich/profil", !persistentNavigation)
}
composable(Destinations.MemberApi.route) {
PendingPage(navController, "API-Dokumentation", "/mitgliederbereich/api", !persistentNavigation)
}
composable(Destinations.CmsNewsletter.route) {
PendingPage(navController, "Newsletter", "/cms/newsletter", !persistentNavigation)
}
composable(Destinations.CmsContactRequests.route) {
PendingPage(navController, "Kontaktanfragen", "/cms/kontaktanfragen", !persistentNavigation)
}
composable(Destinations.Cms.route) {
PendingPage(navController, "CMS", "/cms", !persistentNavigation)
}
}
}
}
}
private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") &&
route != Destinations.Spielsysteme.route
navigate(route) {
launchSingleTop = !isTeamDetail
restoreState = !isTeamDetail
popUpTo(Destinations.Home.route) {
saveState = !isTeamDetail
}
}
}

View File

@@ -0,0 +1,65 @@
package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NavigationUiState(
val teams: List<Mannschaft> = emptyList(),
val hasGalleryImages: Boolean = false,
val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(),
) {
val isAdmin: Boolean get() = "admin" in roles
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
}
@HiltViewModel
class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state
init {
loadNavigationData()
}
fun loadNavigationData() {
viewModelScope.launch {
val teams = async { mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()) }
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await()
_state.value = NavigationUiState(
teams = teams.await(),
hasGalleryImages = gallery.await(),
loggedIn = status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
)
}
}
fun refreshSession() {
viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
_state.value = _state.value.copy(
loggedIn = status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
)
}
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.ui.screens.contact
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
val name by viewModel.name.collectAsState()
val email by viewModel.email.collectAsState()
val message by viewModel.message.collectAsState()
val sending by viewModel.sending.collectAsState()
val result by viewModel.result.collectAsState()
Surface(modifier = Modifier.padding(16.dp)) {
Column {
OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth())
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
Text(if (sending) "Sende…" else "Absenden")
}
if (result != null) {
Text(text = result!!, modifier = Modifier.padding(top = 8.dp))
}
}
}
}

View File

@@ -0,0 +1,61 @@
package de.harheimertc.ui.screens.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ContactRequest
import de.harheimertc.repositories.ContactRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ContactViewModel @Inject constructor(private val repo: ContactRepository) : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name
private val _email = MutableStateFlow("")
val email: StateFlow<String> = _email
private val _message = MutableStateFlow("")
val message: StateFlow<String> = _message
private val _sending = MutableStateFlow(false)
val sending: StateFlow<Boolean> = _sending
private val _result = MutableStateFlow<String?>(null)
val result: StateFlow<String?> = _result
fun onName(v: String) { _name.value = v }
fun onEmail(v: String) { _email.value = v }
fun onMessage(v: String) { _message.value = v }
fun send() {
val n = _name.value.trim()
val e = _email.value.trim()
val m = _message.value.trim()
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) {
_result.value = "Bitte alle Felder ausfüllen"
return
}
viewModelScope.launch {
_sending.value = true
try {
val resp = repo.sendContact(ContactRequest(n, e, m))
if (resp.isSuccessful) {
_result.value = "Nachricht gesendet"
_name.value = ""
_email.value = ""
_message.value = ""
} else {
_result.value = "Fehler: ${resp.code()}"
}
} catch (e: Exception) {
_result.value = "Netzwerkfehler"
} finally {
_sending.value = false
}
}
}
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.ui.screens.gallery
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.components.ImageGrid
@Composable
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
val images by viewModel.images.collectAsState()
val loading by viewModel.loading.collectAsState()
val error by viewModel.error.collectAsState()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (loading) {
CircularProgressIndicator()
} else if (error != null) {
Text(text = "Fehler: $error")
} else {
ImageGrid(images = images)
}
}
// load on first composition
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.ui.screens.gallery
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.GalleryRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() {
private val _images = MutableStateFlow<List<String>>(emptyList())
val images: StateFlow<List<String>> = _images
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun load() {
viewModelScope.launch {
_loading.value = true
_error.value = null
repo.fetchImages()
.onSuccess { _images.value = it }
.onFailure { _error.value = it.message ?: "Fehler" }
_loading.value = false
}
}
}

View File

@@ -0,0 +1,430 @@
package de.harheimertc.ui.screens.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Composable
fun HomeScreen(
navController: NavController,
showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
selectedNews?.let { item ->
AlertDialog(
onDismissRequest = { selectedNews = null },
title = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge)
}
},
text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) },
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color.White),
) {
if (showNavigationHeader) {
item {
AppNavigationHeader(
selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate,
)
}
}
item { WebHero() }
item {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
)
}
item {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
if (state.news.isNotEmpty()) {
item {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
)
}
}
item {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
item { HomeFooter() }
}
}
@Composable
private fun WebHero() {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 390.dp)
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop,
)
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Willkommen beim",
style = MaterialTheme.typography.displayLarge,
color = Accent900,
textAlign = TextAlign.Center,
)
Text(
"Harheimer TC",
style = MaterialTheme.typography.displayLarge.copy(fontSize = 40.sp),
fontWeight = FontWeight.Bold,
color = Primary600,
textAlign = TextAlign.Center,
)
Text(
"Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit $years Jahren",
style = MaterialTheme.typography.bodyLarge,
color = Accent700,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun HomeTermineSection(termine: List<TerminDto>, loading: Boolean, onAll: () -> Unit) {
HomeSection(title = "Kommende Termine", background = Color(0xFFFAFAFA)) {
if (loading) {
LoadingRow("Termine werden geladen...")
} else if (termine.isEmpty()) {
EmptyRow("Keine kommenden Termine")
} else {
termine.forEach { termin -> AppointmentCard(termin) }
}
PrimaryAction("Alle Termine anzeigen", onAll)
}
}
@Composable
private fun HomeGamesSection(spiele: List<SpielDto>, loading: Boolean, onAll: () -> Unit) {
HomeSection(title = "Nächste Spiele", background = Color.White) {
if (loading) {
LoadingRow("Spielplan wird geladen...")
} else if (spiele.isEmpty()) {
EmptyRow("Derzeit sind keine Spiele geplant.")
} else {
spiele.forEach { spiel -> MatchCard(spiel) }
}
PrimaryAction("Alle Spiele anzeigen", onAll)
}
}
@Composable
private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
HomeSection(
title = "Aktuelles",
subtitle = "Die neuesten Nachrichten aus unserem Verein",
background = Color.White,
) {
news.forEach { item ->
Surface(
color = Color(0xFFFAFAFA),
shape = RoundedCornerShape(12.dp),
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp).clickable { onOpen(item) },
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(
item.content,
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
HomeSection(title = null, background = Color(0xFFFAFAFA)) {
ActionCard(
title = "Mitglied werden",
body = "Werden Sie Teil unserer Tischtennisfamilie und profitieren Sie von Training, Wettkämpfen und Gemeinschaft.",
action = "Mehr erfahren",
onClick = onMembership,
)
ActionCard(
title = "Kontakt aufnehmen",
body = "Haben Sie Fragen oder möchten ein kostenloses Probetraining vereinbaren? Wir freuen uns auf Ihre Nachricht!",
action = "Jetzt kontaktieren",
onClick = onContact,
)
}
}
@Composable
private fun HomeSection(
title: String?,
subtitle: String? = null,
background: Color,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().background(background).padding(horizontal = 18.dp, vertical = 38.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
title?.let {
Text(it, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
Spacer(Modifier.height(12.dp))
Box(Modifier.width(74.dp).height(4.dp).background(Primary600))
subtitle?.let { text ->
Spacer(Modifier.height(14.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, color = Accent500, textAlign = TextAlign.Center)
}
Spacer(Modifier.height(26.dp))
}
content()
}
}
@Composable
private fun AppointmentCard(termin: TerminDto) {
Surface(
shape = RoundedCornerShape(9.dp),
color = Color(0xFFF4F4F5),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(
modifier = Modifier.width(65.dp).padding(vertical = 8.dp, horizontal = 3.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(formatDate(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 19.sp)
Text(formatDate(termin.datum, "MMM yyyy"), color = Color.White, style = MaterialTheme.typography.labelSmall)
termin.uhrzeit?.let { Text("$it Uhr", color = Color.White, style = MaterialTheme.typography.labelSmall) }
}
}
Column(Modifier.weight(1f).padding(start = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(termin.titel, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
termin.beschreibung?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent700, maxLines = 2)
}
}
termin.kategorie?.takeIf { it.isNotBlank() }?.let {
Text(
it,
color = Accent700,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.background(Primary100, RoundedCornerShape(14.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
)
}
}
}
}
@Composable
private fun MatchCard(spiel: SpielDto) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatMatchDate(spiel.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
Text(spiel.termin.substringAfter(' ', "-"), style = MaterialTheme.typography.bodyMedium, color = Accent500)
}
Row(verticalAlignment = Alignment.CenterVertically) {
TeamLabel("Heim", spiel.heimMannschaft, Modifier.weight(1f))
Box(
Modifier.size(34.dp).background(Primary100, RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) { Text("vs", color = Primary600, fontWeight = FontWeight.Bold) }
TeamLabel("Gast", spiel.gastMannschaft, Modifier.weight(1f), right = true)
}
spiel.runde?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent500)
}
}
}
}
@Composable
private fun TeamLabel(label: String, value: String, modifier: Modifier, right: Boolean = false) {
Column(modifier.padding(horizontal = 8.dp), horizontalAlignment = if (right) Alignment.End else Alignment.Start) {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(value, fontWeight = FontWeight.SemiBold, color = Accent900, textAlign = if (right) TextAlign.End else TextAlign.Start)
}
}
@Composable
private fun ActionCard(title: String, body: String, action: String, onClick: () -> Unit) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 15.dp).clickable(onClick = onClick),
) {
Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(48.dp).background(Primary100, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) { Text("HTC", color = Primary600, fontWeight = FontWeight.Bold, fontSize = 11.sp) }
Spacer(Modifier.width(14.dp))
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
}
Text(body, style = MaterialTheme.typography.bodyMedium, color = Accent700)
Text("$action >", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun PrimaryAction(label: String, onClick: () -> Unit) {
Spacer(Modifier.height(16.dp))
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
shape = RoundedCornerShape(8.dp),
) { Text("$label >", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) }
}
@Composable
private fun EmptyRow(text: String) {
Surface(color = Accent100, shape = RoundedCornerShape(9.dp), modifier = Modifier.fillMaxWidth()) {
Text(text, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 30.dp, horizontal = 12.dp))
}
}
@Composable
private fun LoadingRow(text: String) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = Primary600)
Spacer(Modifier.height(10.dp))
Text(text, color = Accent500)
}
}
@Composable
private fun HomeFooter() {
Column(
Modifier.fillMaxWidth().background(Accent900).padding(horizontal = 18.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Harheimer TC", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text("Tischtennis in Frankfurt-Harheim seit 1954", color = Accent200, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatDate(value: String, pattern: String): String = runCatching {
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
SimpleDateFormat(pattern, Locale.GERMANY).format(source.parse(value)!!)
}.getOrDefault(value)
private fun formatMatchDate(value: String): String = runCatching {
val source = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
val date = source.parse(value.substringBefore(' '))!!
SimpleDateFormat("EEE dd.MM.yyyy", Locale.GERMANY).format(date)
}.getOrDefault(value.substringBefore(' '))
private fun formatNewsDate(value: String?): String {
if (value.isNullOrBlank()) return ""
return runCatching {
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
}.getOrDefault(value.take(10))
}

View File

@@ -0,0 +1,72 @@
package de.harheimertc.ui.screens.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.HomeRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class HomeUiState(
val loading: Boolean = true,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
val error: Boolean = false,
)
@HiltViewModel
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = false)
repository.fetchHomeData()
.onSuccess { data ->
_state.value = HomeUiState(
loading = false,
termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() }
.take(3),
spiele = data.spiele
.filter { game ->
game.asDate()?.let { date ->
!date.isBefore(LocalDate.now()) &&
!date.isAfter(LocalDate.now().plusDays(7))
} == true
}
.sortedBy { it.asDate() }
.take(3),
news = data.news.take(3),
)
}
.onFailure {
_state.value = HomeUiState(loading = false, error = true)
}
}
}
}
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}.getOrNull()
fun SpielDto.asDate(): LocalDate? = runCatching {
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.getOrNull()

View File

@@ -0,0 +1,131 @@
package de.harheimertc.ui.screens.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
import de.harheimertc.ui.navigation.Destinations
@Composable
fun LoginScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Column(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
Text(
"Mitglieder-Login",
style = MaterialTheme.typography.displayLarge,
color = Accent900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
"Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen.",
color = Accent500,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 9.dp),
)
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (state.restoring) {
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
Text("Sitzung wird geprüft...", color = Accent500)
} else if (!state.loggedIn) {
OutlinedTextField(
value = state.email,
onValueChange = viewModel::setEmail,
label = { Text("E-Mail-Adresse") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::setPassword,
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
}
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Passwort vergessen?")
}
TextButton(onClick = { navController.navigate(Destinations.Register.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Registrierung beantragen")
}
} else {
Text("Angemeldet", style = MaterialTheme.typography.titleLarge, color = Color(0xFF166534))
Text(state.userName.orEmpty(), color = Accent900)
if (state.roles.isNotEmpty()) Text(state.roles.joinToString(", "), color = Accent500)
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) { Text("Abmelden") }
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534)) }
}
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text(
"Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.",
color = Primary900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
}
}
}

View File

@@ -0,0 +1,84 @@
package de.harheimertc.ui.screens.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginUiState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val restoring: Boolean = true,
val loggedIn: Boolean = false,
val userName: String? = null,
val roles: List<String> = emptyList(),
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state
init {
viewModelScope.launch {
repository.status()
.onSuccess { status ->
_state.value = _state.value.copy(
restoring = false,
loggedIn = status.isLoggedIn,
userName = status.user?.name ?: status.user?.email,
roles = status.roles.ifEmpty { status.user?.roles.orEmpty() },
)
}
.onFailure { _state.value = _state.value.copy(restoring = false) }
}
}
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, error = null)
}
fun setPassword(value: String) {
_state.value = _state.value.copy(password = value, error = null)
}
fun login() {
val current = _state.value
if (current.email.isBlank() || current.password.isBlank()) {
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.")
return
}
viewModelScope.launch {
_state.value = current.copy(loading = true, error = null, message = null)
repository.login(current.email, current.password)
.onSuccess { response ->
_state.value = current.copy(
password = "",
loading = false,
restoring = false,
loggedIn = true,
userName = response.user?.name ?: response.user?.email,
roles = response.user?.roles.orEmpty(),
message = "Anmeldung erfolgreich.",
)
}
.onFailure {
_state.value = current.copy(loading = false, error = it.message ?: "Anmeldung fehlgeschlagen.")
}
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
_state.value = LoginUiState(restoring = false, message = "Sie wurden abgemeldet.")
}
}
}

View File

@@ -0,0 +1,176 @@
package de.harheimertc.ui.screens.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun PasswordResetScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: PasswordResetViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
AuthFormPage(
title = "Passwort zurücksetzen",
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.",
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
OutlinedTextField(
state.email,
viewModel::setEmail,
label = { Text("E-Mail-Adresse") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
Text(if (state.loading) "Wird gesendet..." else "Passwort zurücksetzen")
}
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Zurück zum Login")
}
AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.")
}
}
@Composable
fun RegisterScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: RegisterViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val form = state.form
AuthFormPage(
title = "Registrierung",
subtitle = "Beantragen Sie einen Zugang zum Mitgliederbereich.",
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
form.email,
{ viewModel.update(form.copy(email = it)) },
label = { Text("E-Mail-Adresse *") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.phone,
{ viewModel.update(form.copy(phone = it)) },
label = { Text("Telefon") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.birthDate,
{ viewModel.update(form.copy(birthDate = it)) },
label = { Text("Geburtsdatum * (JJJJ-MM-TT)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.password,
{ viewModel.update(form.copy(password = it)) },
label = { Text("Passwort *") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.passwordRepeat,
{ viewModel.update(form.copy(passwordRepeat = it)) },
label = { Text("Passwort wiederholen *") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
Text("Geburtstag im Mitgliederbereich anzeigen")
}
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
Text(if (state.loading) "Wird gesendet..." else "Registrierung beantragen")
}
AuthNotice("Ihre Registrierung muss vor der Anmeldung vom Vorstand freigegeben werden.")
}
}
@Composable
private fun AuthFormPage(
title: String,
subtitle: String,
onBack: () -> Unit,
showBackNavigation: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = onBack) { Text("< Login", color = Primary600, fontWeight = FontWeight.SemiBold) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 22.dp))
Text(subtitle, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 9.dp, bottom = 14.dp))
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) {
content()
}
}
}
}
}
@Composable
private fun MessageLines(error: String?, message: String?) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
message?.let { Text(it, color = Color(0xFF166534)) }
}
@Composable
private fun AuthNotice(text: String) {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Text(text, color = Primary900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(13.dp))
}
}

View File

@@ -0,0 +1,107 @@
package de.harheimertc.ui.screens.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RegistrationVisibility
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PasswordResetUiState(
val email: String = "",
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class PasswordResetViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(PasswordResetUiState())
val state: StateFlow<PasswordResetUiState> = _state
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, error = null)
}
fun submit() {
val email = _state.value.email.trim()
if (!email.contains("@")) {
_state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.")
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.resetPassword(email)
.onSuccess { response ->
_state.value = PasswordResetUiState(message = response.message ?: "Anfrage wurde gesendet.")
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = "Anfrage konnte nicht gesendet werden.")
}
}
}
}
data class RegisterFormState(
val name: String = "",
val email: String = "",
val phone: String = "",
val birthDate: String = "",
val password: String = "",
val passwordRepeat: String = "",
val showBirthday: Boolean = true,
)
data class RegisterUiState(
val form: RegisterFormState = RegisterFormState(),
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class RegisterViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(RegisterUiState())
val state: StateFlow<RegisterUiState> = _state
fun update(form: RegisterFormState) {
_state.value = _state.value.copy(form = form, error = null)
}
fun submit() {
val form = _state.value.form
val error = when {
form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() ->
"Bitte alle Pflichtfelder ausfüllen."
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein."
form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein."
else -> null
}
if (error != null) {
_state.value = _state.value.copy(error = error)
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.register(
RegistrationRequest(
name = form.name.trim(),
email = form.email.trim(),
phone = form.phone.trim().takeIf(String::isNotBlank),
password = form.password,
geburtsdatum = form.birthDate.trim(),
visibility = RegistrationVisibility(showBirthday = form.showBirthday),
),
).onSuccess { response ->
_state.value = RegisterUiState(message = response.message ?: "Registrierung wurde eingereicht.")
}.onFailure {
_state.value = _state.value.copy(loading = false, error = it.message ?: "Registrierung fehlgeschlagen.")
}
}
}
}

View File

@@ -0,0 +1,398 @@
package de.harheimertc.ui.screens.mannschaften
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary700
@Composable
fun MannschaftenScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MannschaftenViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
BackLink(navController, showBackNavigation)
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
else -> items(state.teams) { team ->
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) }
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Spielpläne & Ergebnisse", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften.", color = Accent500)
Button(
onClick = { navController.navigate(Destinations.Spielplan.route) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Zu den Spielplänen") }
}
}
}
}
}
@Composable
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
Surface(
color = Color.White,
shape = RoundedCornerShape(8.dp),
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
) {
Column {
Column(Modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(Primary600, Primary700))).padding(16.dp)) {
Text(team.mannschaft, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 4.dp))
}
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
TeamInfo("Staffelleiter", team.staffelleiter)
TeamInfo("Heimspieltag", team.heimspieltag)
TeamInfo("Spielsystem", team.spielsystem)
Text("${team.spieler.size} Spieler - Details anzeigen", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
fun MannschaftDetailScreen(
slug: String,
navController: NavController,
showBackNavigation: Boolean,
viewModel: MannschaftDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) }
LaunchedEffect(slug) { viewModel.load(slug) }
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { BackLink(navController, showBackNavigation) }
if (state.loading) {
item { Loading() }
} else {
state.team?.let { team ->
item { TeamHeader(team) }
item {
InfoCard("Liga-Informationen") {
TeamInfo("Staffelleiter", team.staffelleiter)
TeamInfo("Telefon", team.telefon)
TeamInfo("Heimspieltag", team.heimspieltag)
TeamInfo("Spielsystem", team.spielsystem)
}
}
item {
InfoCard("Mannschaftsaufstellung") {
team.spieler.forEach { player ->
val captain = player == team.mannschaftsfuehrer
Surface(color = if (captain) Primary100 else Accent100, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(player, color = Accent900)
if (captain) Text("Mannschaftsführer", color = Primary600, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
item {
SchedulePanelHeader(
season = state.season,
selectedTab = selectedTab,
hasTable = team.informationenLink.isNotBlank(),
onSelected = { selectedTab = it },
)
}
when (selectedTab) {
DetailTab.Matches -> {
if (state.matchesError != null) item { Text(state.matchesError.orEmpty(), color = Primary700) }
if (state.matches.isEmpty() && state.matchesError == null) {
item { Text("Für diese Mannschaft sind aktuell keine Spiele vorhanden.", color = Accent500) }
} else {
items(state.matches) { MatchCard(it) }
}
}
DetailTab.Table -> {
when {
state.tableLoading -> item { Loading() }
state.tableError != null -> item { Text(state.tableError.orEmpty(), color = Primary700) }
state.tableRows.isEmpty() -> item { Text("Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.", color = Accent500) }
else -> {
item { TableLegend() }
items(state.tableRows) { TableRow(it) }
}
}
}
}
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } }
}
}
}
private enum class DetailTab { Matches, Table }
@Composable
private fun SchedulePanelHeader(
season: String?,
selectedTab: DetailTab,
hasTable: Boolean,
onSelected: (DetailTab) -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Aktueller Spielplan", style = MaterialTheme.typography.titleLarge, color = Accent900)
season?.let { Text("Saison ${seasonLabel(it)}", color = Accent500) }
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
DetailTabButton("Matches", selectedTab == DetailTab.Matches) { onSelected(DetailTab.Matches) }
if (hasTable) {
DetailTabButton("Tabelle", selectedTab == DetailTab.Table) { onSelected(DetailTab.Table) }
}
}
}
}
}
@Composable
private fun DetailTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Color.White else Accent100,
shape = RoundedCornerShape(6.dp),
shadowElevation = if (selected) 2.dp else 0.dp,
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = if (selected) Primary700 else Accent500,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 15.dp, vertical = 9.dp),
)
}
}
@Composable
private fun TeamHeader(team: Mannschaft) {
Column(Modifier.fillMaxWidth().background(Primary600, RoundedCornerShape(8.dp)).padding(20.dp)) {
Text(team.mannschaft, style = MaterialTheme.typography.displayLarge, color = Color.White)
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 5.dp))
}
}
@Composable
private fun InfoCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun TeamInfo(label: String, value: String) {
Row(Modifier.fillMaxWidth()) {
Text("$label:", color = Accent500, modifier = Modifier.weight(0.38f))
Text(value.ifBlank { "-" }, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.62f))
}
}
@Composable
private fun MatchCard(game: SpielDto) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 1.dp) {
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(game.termin, color = Accent900, fontWeight = FontWeight.SemiBold)
val result = if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) "${game.spieleHeim}:${game.spieleGast}" else "-"
Text(result, color = Primary600, fontWeight = FontWeight.SemiBold)
}
Text("${game.heimMannschaft} - ${game.gastMannschaft}", color = Accent900)
Text("${game.altersklasse} / ${game.staffel.removePrefix("E")}", color = Accent500, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun TableLegend() {
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
val compact = maxWidth < 560.dp
if (compact) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Platz / Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall)
TableMetricsHeader()
}
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Platz", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(55.dp))
Text("Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f))
TableMetricsHeader()
}
}
}
}
}
@Composable
private fun TableRow(row: LeagueTableRowDto) {
val ourTeam = row.teamName.contains("Harheimer TC", ignoreCase = true)
Surface(
color = if (ourTeam) Primary100 else Color.White,
shape = RoundedCornerShape(8.dp),
shadowElevation = 1.dp,
) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
val compact = maxWidth < 560.dp
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
if (compact) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold)
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
Movement(row.movement)
}
TableMetrics(row)
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(55.dp))
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
Movement(row.movement)
}
TableStanding(row)
}
}
Text(
"Sätze ${formatSets(row)} Bälle ${formatGames(row)}",
color = Accent500,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@Composable
private fun Movement(value: String?) {
when (value) {
"rise" -> Text("", color = Color(0xFF15803D))
"fall" -> Text("", color = Primary700)
}
}
@Composable
private fun TableMetricsHeader() {
Row(verticalAlignment = Alignment.CenterVertically) {
TableCell("Sp.", 48.dp, Accent500)
TableCell("S", 38.dp, Accent500)
TableCell("U", 38.dp, Accent500)
TableCell("N", 38.dp, Accent500)
TableCell("Punkte", 66.dp, Accent500)
}
}
@Composable
private fun TableStanding(row: LeagueTableRowDto) = TableMetrics(row)
@Composable
private fun TableMetrics(row: LeagueTableRowDto) {
Row(verticalAlignment = Alignment.CenterVertically) {
TableCell((row.meetings ?: "-").toString(), 48.dp, Accent900)
TableCell((row.won ?: 0).toString(), 38.dp, Accent900)
TableCell((row.tied ?: 0).toString(), 38.dp, Accent900)
TableCell((row.lost ?: 0).toString(), 38.dp, Accent900)
TableCell(formatPoints(row), 66.dp, Accent900)
}
}
@Composable
private fun TableCell(value: String, width: androidx.compose.ui.unit.Dp, color: Color) {
Text(
value,
color = color,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(width),
)
}
@Composable
private fun BackLink(navController: NavController, visible: Boolean) {
if (visible) TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
@Composable
private fun Loading() {
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
@Composable
private fun ErrorPanel(message: String, retry: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Primary700)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}
private fun seasonLabel(value: String): String =
Regex("^(\\d{2})--(\\d{2})$").matchEntire(value)?.let { "20${it.groupValues[1]}/${it.groupValues[2]}" } ?: value
private fun formatSets(row: LeagueTableRowDto): String = "${row.setsWon ?: 0}:${row.setsLost ?: 0}"
private fun formatGames(row: LeagueTableRowDto): String = "${row.gamesWon ?: 0}:${row.gamesLost ?: 0}"
private fun formatPoints(row: LeagueTableRowDto): String = "${row.pointsWon ?: 0}:${row.pointsLost ?: 0}"

View File

@@ -0,0 +1,139 @@
package de.harheimertc.ui.screens.mannschaften
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.SpielplanRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MannschaftenUiState(
val loading: Boolean = true,
val error: String? = null,
val teams: List<Mannschaft> = emptyList(),
)
@HiltViewModel
class MannschaftenViewModel @Inject constructor(
private val repository: MannschaftenRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MannschaftenUiState())
val state: StateFlow<MannschaftenUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = MannschaftenUiState(loading = true)
repository.fetchMannschaften()
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) }
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
}
}
}
data class MannschaftDetailUiState(
val loading: Boolean = true,
val matchesError: String? = null,
val team: Mannschaft? = null,
val matches: List<SpielDto> = emptyList(),
val season: String? = null,
val tableLoading: Boolean = false,
val tableError: String? = null,
val tableRows: List<LeagueTableRowDto> = emptyList(),
)
@HiltViewModel
class MannschaftDetailViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val spielplanRepository: SpielplanRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MannschaftDetailUiState())
val state: StateFlow<MannschaftDetailUiState> = _state
private var loadedSlug: String? = null
fun load(slug: String) {
if (loadedSlug == slug) return
loadedSlug = slug
viewModelScope.launch {
_state.value = MannschaftDetailUiState(loading = true)
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
if (team == null) {
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
return@launch
}
spielplanRepository.fetchSpielplan()
.onSuccess { plan ->
_state.value = MannschaftDetailUiState(
loading = false,
team = team,
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
season = plan.season,
)
if (team.informationenLink.isNotBlank()) {
loadTable(team, plan.season)
}
}
.onFailure {
_state.value = MannschaftDetailUiState(
loading = false,
team = team,
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
)
}
}
}
private suspend fun loadTable(team: Mannschaft, season: String?) {
_state.value = _state.value.copy(tableLoading = true, tableError = null)
spielplanRepository.fetchTeamTable(team.mannschaft, season)
.onSuccess { response ->
_state.value = _state.value.copy(
tableLoading = false,
tableRows = response.table?.table?.leagueTable.orEmpty(),
)
}
.onFailure {
_state.value = _state.value.copy(
tableLoading = false,
tableError = "Tabelle konnte nicht geladen werden.",
tableRows = emptyList(),
)
}
}
private fun matchesTeam(game: SpielDto, cmsName: String): Boolean {
val variant = when (cmsName) {
"Erwachsene 1" -> "harheimer tc"
"Erwachsene 2" -> "harheimer tc ii"
"Erwachsene 3" -> "harheimer tc iii"
"Erwachsene 4" -> "harheimer tc iv"
"Erwachsene 5" -> "harheimer tc v"
"Jugendmannschaft", "Jugend I" -> "harheimer tc"
else -> return false
}
fun exact(value: String): Boolean =
if (variant == "harheimer tc") {
value == variant || (value.startsWith("$variant ") && !Regex("harheimer tc\\s+[ivx]+").containsMatchIn(value))
} else value == variant || value.startsWith("$variant ")
val home = game.heimMannschaft.lowercase()
val away = game.gastMannschaft.lowercase()
if (!exact(home) && !exact(away)) return false
return if (cmsName.startsWith("Erwachsene")) {
(exact(home) && game.heimAltersklasse.contains("Erwachsene", true)) ||
(exact(away) && game.gastAltersklasse.contains("Erwachsene", true))
} else {
game.heimAltersklasse.contains("Jugend", true) || game.gastAltersklasse.contains("Jugend", true) ||
home.contains("jugend") || away.contains("jugend")
}
}
}

View File

@@ -0,0 +1,213 @@
package de.harheimertc.ui.screens.membership
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun MembershipScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: MembershipViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val form = state.form
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Mitgliedschaft", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 14.dp))
Text(
"Werden Sie Teil unserer Tischtennis-Familie.",
color = Accent500,
modifier = Modifier.padding(top = 7.dp, bottom = 10.dp),
)
}
item {
InfoCard(
title = "Vereinssatzung",
text = "Unsere aktuelle Vereinssatzung und der Mitgliedsantrag stehen auf der Website als PDF bereit.",
)
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
FormHeading("Persönliche Daten")
TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) }
TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) }
TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) }
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) }
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) }
}
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) }
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) }
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
FormHeading("Mitgliedschaftsart")
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
FeeInfo()
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) {
viewModel.update(form.copy(lastschrift = it))
}
FormHeading("Bankdaten für SEPA-Lastschrift")
TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) }
TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) }
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
FormHeading("Datenschutz und Vereinssatzung")
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) {
viewModel.update(form.copy(datenschutz = it))
}
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) {
viewModel.update(form.copy(satzung = it))
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534), fontWeight = FontWeight.SemiBold) }
Button(onClick = viewModel::submit, enabled = !state.sending, modifier = Modifier.fillMaxWidth()) {
if (state.sending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
Spacer(Modifier.width(8.dp))
}
Text(if (state.sending) "Formular wird erstellt..." else "Beitrittsformular erstellen")
}
state.pdfUri?.let { uri ->
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(uri), "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
},
)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Erstelltes PDF öffnen") }
}
}
}
}
item {
Surface(color = Primary600, shape = RoundedCornerShape(14.dp)) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch Fragen zur Mitgliedschaft?", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text("Kontaktieren Sie uns - wir beraten Sie gerne persönlich.", color = Primary100, modifier = Modifier.padding(vertical = 12.dp))
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) {
Text("Jetzt Kontakt aufnehmen")
}
}
}
}
}
}
@Composable
private fun FormHeading(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, color = Accent900, modifier = Modifier.padding(top = 12.dp))
}
@Composable
private fun TextInput(
label: String,
value: String,
modifier: Modifier = Modifier.fillMaxWidth(),
keyboard: KeyboardType = KeyboardType.Text,
onChange: (String) -> Unit,
) {
OutlinedTextField(
value = value,
onValueChange = onChange,
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
modifier = modifier,
singleLine = true,
)
}
@Composable
private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onClick)
Text(label, color = Accent700)
}
}
@Composable
private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) {
Row(verticalAlignment = Alignment.Top) {
Checkbox(checked = selected, onCheckedChange = onChange)
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
}
@Composable
private fun FeeInfo() {
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Jährlicher Mitgliedsbeitrag", fontWeight = FontWeight.SemiBold, color = Accent900)
Text("120 EUR Erwachsene | 72 EUR Jugendliche | 30 EUR passive Mitglieder", color = Accent700)
}
}
}
@Composable
private fun InfoCard(title: String, text: String) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(text, color = Accent500, modifier = Modifier.padding(top = 7.dp))
}
}
}

View File

@@ -0,0 +1,93 @@
package de.harheimertc.ui.screens.membership
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.MembershipRequest
import de.harheimertc.repositories.MembershipRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MembershipFormState(
val vorname: String = "",
val nachname: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val geburtsdatum: String = "",
val email: String = "",
val telefon: String = "",
val art: String = "aktiv",
val kontoinhaber: String = "",
val iban: String = "",
val bic: String = "",
val bank: String = "",
val lastschrift: Boolean = false,
val datenschutz: Boolean = false,
val satzung: Boolean = false,
)
data class MembershipUiState(
val form: MembershipFormState = MembershipFormState(),
val sending: Boolean = false,
val message: String? = null,
val error: String? = null,
val pdfUri: String? = null,
)
@HiltViewModel
class MembershipViewModel @Inject constructor(private val repository: MembershipRepository) : ViewModel() {
private val _state = MutableStateFlow(MembershipUiState())
val state: StateFlow<MembershipUiState> = _state
fun update(form: MembershipFormState) {
_state.value = _state.value.copy(form = form, error = null)
}
fun submit() {
val form = _state.value.form
validate(form)?.let {
_state.value = _state.value.copy(error = it)
return
}
viewModelScope.launch {
_state.value = _state.value.copy(sending = true, error = null, message = null)
val request = MembershipRequest(
vorname = form.vorname.trim(),
nachname = form.nachname.trim(),
strasse = form.strasse.trim(),
plz = form.plz.trim(),
ort = form.ort.trim(),
geburtsdatum = form.geburtsdatum.trim(),
email = form.email.trim(),
telefon_mobil = form.telefon.trim().takeIf(String::isNotBlank),
mitgliedschaftsart = form.art,
lastschrift_erlaubt = form.lastschrift,
kontoinhaber = form.kontoinhaber.trim(),
iban = form.iban.trim(),
bic = form.bic.trim().takeIf(String::isNotBlank),
bank = form.bank.trim().takeIf(String::isNotBlank),
datenschutz_einverstanden = form.datenschutz,
satzung_anerkannt = form.satzung,
)
repository.submit(request)
.onSuccess { document ->
_state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri)
}
.onFailure {
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
}
}
}
private fun validate(form: MembershipFormState): String? = when {
listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban)
.any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen."
!form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben."
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
!form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen."
else -> null
}
}

View File

@@ -0,0 +1,88 @@
package de.harheimertc.ui.screens.publicpages
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConfigResponse
import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.repositories.PublicPagesRepository
import de.harheimertc.repositories.Spielsystem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PublicConfigUiState(
val loading: Boolean = true,
val error: String? = null,
val config: ConfigResponse? = null,
)
@HiltViewModel
class PublicConfigViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
private val _state = MutableStateFlow(PublicConfigUiState())
val state: StateFlow<PublicConfigUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = PublicConfigUiState()
repository.fetchConfig()
.onSuccess { _state.value = PublicConfigUiState(loading = false, config = it) }
.onFailure { _state.value = PublicConfigUiState(loading = false, error = "Inhalte konnten nicht geladen werden.") }
}
}
}
data class SpielsystemeUiState(
val loading: Boolean = true,
val error: String? = null,
val systems: List<Spielsystem> = emptyList(),
)
@HiltViewModel
class SpielsystemeViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
private val _state = MutableStateFlow(SpielsystemeUiState())
val state: StateFlow<SpielsystemeUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = SpielsystemeUiState()
repository.fetchSpielsysteme()
.onSuccess { _state.value = SpielsystemeUiState(loading = false, systems = it) }
.onFailure { _state.value = SpielsystemeUiState(loading = false, error = "Spielsysteme konnten nicht geladen werden.") }
}
}
}
data class VereinsmeisterschaftenUiState(
val loading: Boolean = true,
val error: String? = null,
val results: List<MeisterschaftResult> = emptyList(),
)
@HiltViewModel
class VereinsmeisterschaftenViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
private val _state = MutableStateFlow(VereinsmeisterschaftenUiState())
val state: StateFlow<VereinsmeisterschaftenUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = VereinsmeisterschaftenUiState()
repository.fetchVereinsmeisterschaften()
.onSuccess { _state.value = VereinsmeisterschaftenUiState(loading = false, results = it) }
.onFailure { _state.value = VereinsmeisterschaftenUiState(loading = false, error = "Vereinsmeisterschaften konnten nicht geladen werden.") }
}
}
}

View File

@@ -0,0 +1,112 @@
package de.harheimertc.ui.screens.publicpages
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import androidx.navigation.NavController
import de.harheimertc.BuildConfig
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
@Composable
internal fun PublicPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
subtitle: String? = null,
content: LazyListScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
subtitle?.let { Text(it, color = Accent500, modifier = Modifier.padding(top = 8.dp)) }
}
content()
}
}
@Composable
internal fun PublicCard(title: String? = null, content: @Composable () -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
title?.let { Text(it, style = MaterialTheme.typography.titleLarge, color = Accent900) }
content()
}
}
}
@Composable
internal fun PublicLoading() {
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
@Composable
internal fun PublicError(message: String, retry: () -> Unit) {
PublicCard {
Text(message, color = MaterialTheme.colorScheme.error)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}
@Composable
internal fun HtmlContent(html: String) {
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { context ->
TextView(context).apply {
textSize = 17f
setTextColor(android.graphics.Color.rgb(63, 63, 70))
movementMethod = LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.2f)
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
}
internal fun Context.openPublicUri(value: String) {
val uri = if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("mailto:")) {
value
} else {
BuildConfig.API_BASE_URL.trimEnd('/') + "/" + value.trimStart('/')
}
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
}

View File

@@ -0,0 +1,281 @@
package de.harheimertc.ui.screens.publicpages
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.repositories.Spielsystem
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
@Composable
fun SpielsystemeScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: SpielsystemeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedCategory by rememberSaveable { mutableStateOf("Alle Kategorien") }
val categories = state.systems.map(Spielsystem::category).filter(String::isNotBlank).distinct().sorted()
val displayed = if (selectedCategory == "Alle Kategorien") state.systems else state.systems.filter { it.category == selectedCategory }
PublicPage(
navController,
showBackNavigation,
"Spielsysteme",
"Übersicht der verschiedenen Mannschafts-Spielsysteme im Tischtennis",
) {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> {
item {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
(categories + "Alle Kategorien").forEach { category ->
CategoryButton(category, category == selectedCategory) { selectedCategory = category }
}
}
}
displayed.forEach { system -> item { SpielsystemCard(system) } }
item {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(
"Die Spielsysteme werden je nach Liga und Verband unterschiedlich eingesetzt. Regionale Ligen verwenden meist das Bundessystem oder das Braunschweiger System.",
color = Primary100,
)
}
}
}
}
}
}
}
@Composable
private fun CategoryButton(label: String, selected: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = if (selected) Primary600 else Color.White, contentColor = if (selected) Color.White else Accent700),
shape = RoundedCornerShape(7.dp),
) { Text(label) }
}
@Composable
private fun SpielsystemCard(system: Spielsystem) {
PublicCard {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(system.name, style = MaterialTheme.typography.titleLarge, color = Accent900, modifier = Modifier.weight(1f))
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
Text(system.category, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
}
}
Text(system.teamSize, color = Primary600, fontWeight = FontWeight.SemiBold)
Text(system.description, color = Accent700)
Text("Spielabfolge: ${system.sequence}", color = Accent500)
Text("Anzahl Spiele: ${system.gameCount}", color = Accent500)
Text("Besonderheiten: ${system.features}", color = Accent500)
}
}
@Composable
fun VereinsmeisterschaftenScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: VereinsmeisterschaftenViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedYear by rememberSaveable { mutableStateOf("Alle Jahre") }
var selectedPortrait by rememberSaveable { mutableStateOf<Pair<String, String>?>(null) }
val years = state.results.map(MeisterschaftResult::year).filter(String::isNotBlank).distinct().sortedDescending()
val displayed = if (selectedYear == "Alle Jahre") state.results else state.results.filter { it.year == selectedYear }
PublicPage(
navController,
showBackNavigation,
"Vereinsmeisterschaften",
"Die Ergebnisse unserer Vereinsmeisterschaften der letzten Jahre",
) {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> {
item {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
(years + "Alle Jahre").forEach { year ->
CategoryButton(year, selectedYear == year) { selectedYear = year }
}
}
}
displayed.groupBy(MeisterschaftResult::year).toSortedMap(compareByDescending { it }).forEach { (year, results) ->
item { ChampionshipYearCard(year, results) { image, name -> selectedPortrait = image to name } }
}
item {
val singleWinners = state.results.count { it.rank == "1" && it.category.contains("Einzel") }
val doublesWinners = state.results.count { it.rank == "1" && it.category == "Doppel" }
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Row(Modifier.fillMaxWidth().padding(18.dp), horizontalArrangement = Arrangement.SpaceAround) {
Statistic("${years.size}", "Jahre")
Statistic("$singleWinners", "Einzelgewinner")
Statistic("$doublesWinners", "Doppelgewinner")
}
}
}
}
}
}
selectedPortrait?.let { (image, name) ->
AlertDialog(
onDismissRequest = { selectedPortrait = null },
confirmButton = {
Button(onClick = { selectedPortrait = null }) { Text("Schließen") }
},
title = { Text(name) },
text = {
AsyncImage(
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$image",
contentDescription = name,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
},
)
}
}
@Composable
private fun ChampionshipYearCard(year: String, results: List<MeisterschaftResult>, onImage: (String, String) -> Unit) {
PublicCard(year) {
results.map(MeisterschaftResult::note).firstOrNull(String::isNotBlank)?.let { note ->
Surface(color = Color(0xFFFEF3C7), shape = RoundedCornerShape(6.dp)) {
Text(note, color = Color(0xFF92400E), modifier = Modifier.fillMaxWidth().padding(10.dp))
}
}
results.filter { it.category.isNotBlank() }.groupBy(MeisterschaftResult::category).forEach { (category, placements) ->
Text(category, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp))
placements.sortedBy { it.rank.toIntOrNull() ?: Int.MAX_VALUE }.forEach { result ->
val names = listOf(result.playerOne, result.playerTwo).filter(String::isNotBlank).joinToString(" / ")
Surface(color = if (result.rank == "1") Color(0xFFFEF3C7) else Accent100, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("${result.rank}.", color = Accent900, fontWeight = FontWeight.Bold)
result.imageOne.takeIf(String::isNotBlank)?.let { image ->
Portrait(image, result.playerOne) { onImage(image, result.playerOne) }
}
Text(names, color = Accent900, modifier = Modifier.weight(1f))
result.imageTwo.takeIf(String::isNotBlank)?.let { image ->
Portrait(image, result.playerTwo) { onImage(image, result.playerTwo) }
}
}
}
}
}
}
}
@Composable
private fun Portrait(filename: String, name: String, onClick: () -> Unit) {
AsyncImage(
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=48&height=48",
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(32.dp).clip(CircleShape).clickable(onClick = onClick),
)
}
@Composable
private fun Statistic(value: String, label: String) {
Column {
Text(value, color = Color.White, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(label, color = Primary100, style = MaterialTheme.typography.labelSmall)
}
}
@Composable
fun RegelnScreen(navController: NavController, showBackNavigation: Boolean) {
val context = LocalContext.current
val dttbUrl = "https://www.tischtennis.de/dttb/regeln-satzung/satzung-ordnungen.html"
PublicPage(
navController,
showBackNavigation,
"Tischtennis-Regeln",
"Offizielle Regeln und Bestimmungen für den Tischtennissport",
) {
item {
PublicCard("Offizielles ITTF-Reglement") {
Text("Die offiziellen Regeln des Internationalen Tischtennis-Verbands gelten weltweit für Wettkämpfe und Turniere.", color = Accent700)
Button(onClick = { context.openPublicUri(dttbUrl) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
Text("Offizielle Regeln öffnen")
}
}
}
item {
PublicCard("Tischtennis-Regeln Light") {
Text("Eine kompakte Übersicht der wichtigsten Regeln für Einsteiger und Hobbyspieler.", color = Accent700)
Button(
onClick = { context.openPublicUri("/documents/Tischtennisregeln%20light.pdf") },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Regeln Light als PDF öffnen") }
}
}
item { Text("Grundregeln im Überblick", style = MaterialTheme.typography.titleLarge, color = Accent900) }
listOf(
"Spielfeld" to "Tisch: 2,74 m x 1,525 m, Höhe: 76 cm. Netz: 15,25 cm hoch.",
"Ball" to "Durchmesser: 40 mm. Gewicht: 2,7 g.",
"Schläger" to "Belag: schwarz und farbig. Holz: mindestens 85 Prozent.",
"Aufschlag" to "Ball muss sichtbar mindestens 16 cm hochgeworfen werden.",
"Satz" to "Gewinn bei 11 Punkten mit mindestens 2 Punkten Vorsprung.",
"Spiel" to "Best of 5 oder 7 Sätze; Aufschlagwechsel alle 2 Punkte.",
).forEach { rule ->
item { PublicCard(rule.first) { Text(rule.second, color = Accent700) } }
}
item {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text("Für regionale Turniere können ergänzende Bestimmungen gelten.", color = Primary100)
Button(onClick = { context.openPublicUri(dttbUrl) }) { Text("DTTB-Regeln und Ordnungen") }
}
}
}
}
}

View File

@@ -0,0 +1,219 @@
package de.harheimertc.ui.screens.publicpages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import androidx.compose.ui.layout.ContentScale
import de.harheimertc.BuildConfig
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.BoardMemberDto
import de.harheimertc.data.LinkSectionDto
import de.harheimertc.repositories.linkSections
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
@Composable
fun AboutScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
CmsHtmlScreen(navController, showBackNavigation, "Über uns", state.config?.seiten?.ueberUns, state.loading, state.error, viewModel::load)
}
@Composable
fun GeschichteScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
CmsHtmlScreen(navController, showBackNavigation, "Geschichte", state.config?.seiten?.geschichte, state.loading, state.error, viewModel::load)
}
@Composable
private fun CmsHtmlScreen(
navController: NavController,
showBackNavigation: Boolean,
title: String,
html: String?,
loading: Boolean,
error: String?,
retry: () -> Unit,
) {
PublicPage(navController, showBackNavigation, title) {
when {
loading -> item { PublicLoading() }
error != null -> item { PublicError(error, retry) }
html.isNullOrBlank() -> item { Text("Für diese Seite ist derzeit kein Inhalt hinterlegt.", color = Accent500) }
else -> item { PublicCard { HtmlContent(html) } }
}
}
}
@Composable
fun SatzungScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(navController, showBackNavigation, "Satzung") {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> state.config?.seiten?.satzung?.let { satzung ->
item {
PublicCard {
if (satzung.content.isNotBlank()) HtmlContent(satzung.content)
if (satzung.pdfUrl.isNotBlank()) {
Button(
onClick = { context.openPublicUri(satzung.pdfUrl) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Satzung als PDF öffnen") }
}
}
}
}
}
}
}
@Composable
fun VorstandScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(
navController,
showBackNavigation,
"Vorstand",
"Unser Vorstandsteam leitet den Harheimer TC.",
) {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> {
val vorstand = state.config?.vorstand
listOf(
"Vorsitzender" to vorstand?.vorsitzender,
"Stellvertreter" to vorstand?.stellvertreter,
"Kassenwart" to vorstand?.kassenwart,
"Schriftführer" to vorstand?.schriftfuehrer,
"Sportwart" to vorstand?.sportwart,
"Jugendwart" to vorstand?.jugendwart,
).filter { !it.second?.vorname.isNullOrBlank() }.forEach { (role, member) ->
item { BoardMemberCard(role, member!!, onMail = { context.openPublicUri("mailto:$it") }) }
}
}
}
}
}
@Composable
private fun BoardMemberCard(role: String, member: BoardMemberDto, onMail: (String) -> Unit) {
PublicCard {
member.imageFilename?.takeIf(String::isNotBlank)?.let { filename ->
AsyncImage(
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=120&height=120",
contentDescription = "${member.vorname} ${member.nachname}",
contentScale = ContentScale.Crop,
modifier = Modifier.size(64.dp).clip(CircleShape),
)
}
Surface(color = Primary100, shape = RoundedCornerShape(6.dp)) {
Text(role, color = Primary600, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp))
}
Text("${member.vorname} ${member.nachname}", style = MaterialTheme.typography.titleLarge, color = Accent900)
member.strasse.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
if (member.plz.isNotBlank() || member.ort.isNotBlank()) Text("${member.plz} ${member.ort}".trim(), color = Accent700)
member.telefon.takeIf(String::isNotBlank)?.let { Text("Tel. $it", color = Accent700) }
member.email.takeIf(String::isNotBlank)?.let { email ->
Button(
onClick = { onMail(email) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text(email) }
}
}
}
@Composable
fun LinksScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(
navController,
showBackNavigation,
"Links",
"Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.",
) {
when {
state.loading -> item { PublicLoading() }
else -> (state.config ?: ConfigResponse()).linkSections().forEach { section ->
item { LinkSectionCard(section) { context.openPublicUri(it) } }
}
}
}
}
@Composable
private fun LinkSectionCard(section: LinkSectionDto, onOpen: (String) -> Unit) {
PublicCard(section.title) {
section.items.forEach { item ->
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
Column(Modifier.fillMaxWidth().padding(11.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row {
Text(
item.label,
color = Primary600,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
Button(
onClick = { onOpen(item.href) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Öffnen") }
}
item.description.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
}
}
}
}
}

View File

@@ -0,0 +1,253 @@
package de.harheimertc.ui.screens.spielplan
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun SpielplanScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: SpielplanViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Spielpläne", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 15.dp))
Text("Alle Spielpläne der Mannschaften", color = Accent500, modifier = Modifier.padding(top = 5.dp, bottom = 15.dp))
}
item {
FilterPanel(
state = state,
onSeason = viewModel::selectSeason,
onCompetition = viewModel::selectWettbewerb,
onTeam = viewModel::selectTeam,
onReload = { viewModel.load() },
)
}
if (state.loading) {
item { LoadingPlan() }
} else if (state.error != null) {
item {
StatusPanel("Fehler beim Laden", state.error.orEmpty())
Button(onClick = { viewModel.load() }, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
}
} else if (state.spiele.isEmpty()) {
item { StatusPanel("Keine Spielpläne verfügbar", "Es wurden noch keine Spielplandaten hochgeladen.") }
} else {
item {
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Spielplan", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
Text(
"${state.selectedWettbewerb.label} - ${state.filtered.size} von ${state.spiele.size} Einträgen",
color = Accent500,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
if (state.filtered.isEmpty()) {
item { StatusPanel("Keine Einträge", "Für die gewählte Filterung sind keine Spiele vorhanden.") }
} else {
items(state.filtered) { game -> MatchRow(game) }
}
}
}
}
@Composable
private fun FilterPanel(
state: SpielplanUiState,
onSeason: (String) -> Unit,
onCompetition: (Wettbewerb) -> Unit,
onTeam: (String) -> Unit,
onReload: () -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(15.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (state.seasons.isNotEmpty()) {
SelectMenu(
label = "Saison",
selected = state.seasons.firstOrNull { it.slug == state.selectedSeason }?.label ?: state.selectedSeason.orEmpty(),
options = state.seasons,
text = { it.label },
onSelected = { onSeason(it.slug) },
)
}
SelectMenu(
label = "Wettbewerb",
selected = state.selectedWettbewerb.label,
options = Wettbewerb.entries.toList(),
text = { it.label },
onSelected = onCompetition,
)
SelectMenu(
label = "Mannschaft",
selected = state.selectedTeam,
options = state.teams,
text = { it },
onSelected = onTeam,
)
Button(
onClick = onReload,
enabled = !state.loading,
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
modifier = Modifier.fillMaxWidth(),
) { Text("Spielplan laden") }
Text(
"${state.selectedWettbewerb.label} - ${state.selectedTeam} (${state.filtered.size} von ${state.spiele.size} Einträgen)",
color = Accent500,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun <T> SelectMenu(
label: String,
selected: String,
options: List<T>,
text: (T) -> String,
onSelected: (T) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent700)
Box {
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
Text("v")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(text(option)) },
onClick = {
open = false
onSelected(option)
},
)
}
}
}
}
}
@Composable
private fun MatchRow(game: SpielDto) {
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 1.dp) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatTermin(game.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) {
Text("${game.spieleHeim}:${game.spieleGast}", color = Primary600, fontWeight = FontWeight.Bold)
}
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(game.heimMannschaft.ifBlank { "-" }, modifier = Modifier.weight(1f), color = Accent900)
Text(" - ", color = Accent500)
Text(
game.gastMannschaft.ifBlank { "-" },
modifier = Modifier.weight(1f),
color = Accent900,
textAlign = TextAlign.End,
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(game.runde.orEmpty(), style = MaterialTheme.typography.labelSmall, color = Accent500)
Column(horizontalAlignment = Alignment.End) {
Text(game.altersklasse.ifBlank { "-" }, style = MaterialTheme.typography.labelSmall, color = Accent700)
Text(formatStaffel(game.staffel), style = MaterialTheme.typography.labelSmall, color = Accent500)
}
}
}
}
}
@Composable
private fun LoadingPlan() {
Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
}
}
@Composable
private fun StatusPanel(title: String, body: String) {
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(vertical = 34.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
Text(body, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
}
}
}
private fun formatTermin(value: String): String = runCatching {
val source = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY)
SimpleDateFormat("EEE dd.MM.yyyy, HH:mm", Locale.GERMANY).format(source.parse(value)!!)
}.getOrDefault(value)
private fun formatStaffel(value: String): String =
value.trim().removePrefix("E").trimStart()

View File

@@ -0,0 +1,102 @@
package de.harheimertc.ui.screens.spielplan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.repositories.SpielplanRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class Wettbewerb(val label: String) {
Punktrunde("Punktrunde"),
Pokal("Pokal"),
Alle("Alle"),
}
data class SpielplanUiState(
val loading: Boolean = true,
val error: String? = null,
val spiele: List<SpielDto> = emptyList(),
val seasons: List<SeasonDto> = emptyList(),
val selectedSeason: String? = null,
val selectedWettbewerb: Wettbewerb = Wettbewerb.Punktrunde,
val selectedTeam: String = "Gesamt",
) {
val teams: List<String>
get() = listOf("Gesamt", "Erwachsene", "Nachwuchs") +
spiele.flatMap { listOf(it.heimMannschaft, it.gastMannschaft) }
.filter { it.contains("Harheimer TC", ignoreCase = true) }
.distinct()
.sorted()
val filtered: List<SpielDto>
get() = spiele.filter(::matchesCompetition).filter(::matchesTeam)
private fun matchesCompetition(game: SpielDto): Boolean {
val text = "${game.runde.orEmpty()} ${game.staffel} ${game.liga}".lowercase()
val pokal = "pokal" in text
return when (selectedWettbewerb) {
Wettbewerb.Punktrunde -> !pokal
Wettbewerb.Pokal -> pokal
Wettbewerb.Alle -> true
}
}
private fun matchesTeam(game: SpielDto): Boolean {
val homeHtc = game.heimMannschaft.contains("Harheimer TC", ignoreCase = true)
val awayHtc = game.gastMannschaft.contains("Harheimer TC", ignoreCase = true)
return when (selectedTeam) {
"Gesamt" -> true
"Erwachsene" -> (homeHtc && game.heimAltersklasse.contains("Erwachsene", ignoreCase = true)) ||
(awayHtc && game.gastAltersklasse.contains("Erwachsene", ignoreCase = true))
"Nachwuchs" -> (homeHtc && isYouth(game.heimAltersklasse, game.heimMannschaft)) ||
(awayHtc && isYouth(game.gastAltersklasse, game.gastMannschaft))
else -> game.heimMannschaft == selectedTeam || game.gastMannschaft == selectedTeam
}
}
private fun isYouth(age: String, team: String): Boolean =
age.contains("Jugend", ignoreCase = true) || team.contains("Jugend", ignoreCase = true)
}
@HiltViewModel
class SpielplanViewModel @Inject constructor(private val repository: SpielplanRepository) : ViewModel() {
private val _state = MutableStateFlow(SpielplanUiState())
val state: StateFlow<SpielplanUiState> = _state
init {
load()
}
fun load(season: String? = _state.value.selectedSeason) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.fetchSpielplan(season)
.onSuccess { response ->
_state.value = _state.value.copy(
loading = false,
spiele = response.data,
seasons = response.seasons.ifEmpty { _state.value.seasons },
selectedSeason = response.season ?: season,
)
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = "Spielplan konnte nicht geladen werden.")
}
}
}
fun selectSeason(slug: String) = load(slug)
fun selectWettbewerb(value: Wettbewerb) {
_state.value = _state.value.copy(selectedWettbewerb = value)
}
fun selectTeam(value: String) {
_state.value = _state.value.copy(selectedTeam = value)
}
}

View File

@@ -0,0 +1,166 @@
package de.harheimertc.ui.screens.termine
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun TermineScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: TermineViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
PageHeader(onBack = { navController.popBackStack() })
}
Column(
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Termine & Events", style = MaterialTheme.typography.displayLarge, color = Accent900)
Spacer(Modifier.size(13.dp))
Box(Modifier.width(74.dp).size(width = 74.dp, height = 4.dp).background(Primary600))
Text(
"Alle kommenden Termine und Veranstaltungen des Harheimer TC",
color = Accent500,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 17.dp),
)
}
}
if (state.loading) {
item { LoadingPanel() }
} else if (state.error != null) {
item {
EmptyPanel(state.error.orEmpty())
Button(onClick = viewModel::load, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
}
} else if (state.termine.isEmpty()) {
item { EmptyPanel("Aktuell sind keine Termine geplant. Schauen Sie bald wieder vorbei!") }
} else {
items(state.termine) { termin -> TerminCard(termin) }
}
item { NoticePanel() }
}
}
@Composable
private fun PageHeader(onBack: () -> Unit) {
TextButton(onClick = onBack) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun TerminCard(termin: TerminDto) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) {
Surface(color = Primary600, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.width(64.dp).padding(vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(datePart(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
Text(datePart(termin.datum, "MMM"), color = Color.White, style = MaterialTheme.typography.labelSmall)
}
}
Column(Modifier.weight(1f).padding(horizontal = 13.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(termin.titel, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, color = Accent900)
termin.beschreibung?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
Text(fullDate(termin), style = MaterialTheme.typography.labelSmall, color = Accent500)
}
termin.kategorie?.takeIf(String::isNotBlank)?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = Primary900,
modifier = Modifier.background(Primary100, RoundedCornerShape(16.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
)
}
}
}
}
@Composable
private fun LoadingPanel() {
Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
}
}
@Composable
private fun EmptyPanel(message: String) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine kommenden Termine", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(message, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
}
}
}
@Composable
private fun NoticePanel() {
Surface(color = Color(0xFFFEF2F2), shape = RoundedCornerShape(12.dp), modifier = Modifier.padding(top = 16.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp)) {
Text("Hinweis", fontWeight = FontWeight.SemiBold, color = Primary900)
Text(
"Alle Termine sind vorbehaltlich kurzfristiger Änderungen. Bei Fragen zu einzelnen Veranstaltungen kontaktieren Sie uns gerne.",
color = Primary900,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
private fun datePart(value: String, pattern: String): String = runCatching {
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY).parse(value)!!
SimpleDateFormat(pattern, Locale.GERMANY).format(parsed)
}.getOrDefault(value)
private fun fullDate(termin: TerminDto): String {
val date = datePart(termin.datum, "EEEE, d. MMMM yyyy")
return termin.uhrzeit?.takeIf(String::isNotBlank)?.let { "$date - $it Uhr" } ?: date
}

View File

@@ -0,0 +1,53 @@
package de.harheimertc.ui.screens.termine
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.TermineRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class TermineUiState(
val loading: Boolean = true,
val termine: List<TerminDto> = emptyList(),
val error: String? = null,
)
@HiltViewModel
class TermineViewModel @Inject constructor(private val repository: TermineRepository) : ViewModel() {
private val _state = MutableStateFlow(TermineUiState())
val state: StateFlow<TermineUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = TermineUiState(loading = true)
repository.fetchTermine()
.onSuccess { termine ->
_state.value = TermineUiState(
loading = false,
termine = termine
.filter { it.eventDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
.sortedBy { it.eventDateTime() },
)
}
.onFailure {
_state.value = TermineUiState(loading = false, error = "Termine konnten nicht geladen werden.")
}
}
}
}
private fun TerminDto.eventDateTime(): LocalDateTime? = runCatching {
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}.getOrNull()

View File

@@ -0,0 +1,207 @@
package de.harheimertc.ui.screens.training
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.TrainingTimeDto
import de.harheimertc.data.TrainerDto
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
@Composable
fun TrainingScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: TrainingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Page(
navController = navController,
showBackNavigation = showBackNavigation,
title = "Trainingszeiten",
) {
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
else -> state.config?.let { config ->
item {
ContentCard("Trainingsort") {
Text(config.training.ort.name, color = Accent900, fontWeight = FontWeight.SemiBold)
Text(config.training.ort.strasse, color = Accent700)
Text("${config.training.ort.plz} ${config.training.ort.ort}", color = Accent700)
}
}
item { Text("Trainingszeiten", style = MaterialTheme.typography.titleLarge, color = Accent900) }
val groups = config.training.zeiten.groupBy { it.gruppe }
items(groups.entries.toList()) { group ->
ContentCard(group.key) {
group.value.forEach { time ->
Text("${time.tag}: ${time.von} - ${time.bis} Uhr", color = Primary600, fontWeight = FontWeight.SemiBold)
time.info?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
}
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Interessiert?", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Komm einfach zum Schnuppertraining vorbei oder kontaktiere uns für weitere Informationen.", color = Accent700)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { navController.navigate(Destinations.Anfaenger.route) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
Text("Infos für Anfänger")
}
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) { Text("Kontakt") }
}
}
}
}
}
}
}
}
@Composable
fun TrainerScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: TrainingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Page(navController, showBackNavigation, "Unsere Trainer") {
item { Text("Erfahrene und qualifizierte Trainer für alle Leistungsstufen", color = Accent500) }
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
else -> items(state.config?.trainer.orEmpty()) { trainer -> TrainerCard(trainer) }
}
}
}
@Composable
fun AnfaengerScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: TrainingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Page(navController, showBackNavigation, "Tischtennis für Anfänger") {
item { Text("Du möchtest mit Tischtennis anfangen? Bei uns bist du richtig.", color = Accent500) }
item {
ContentCard("Was du wissen solltest") {
listOf(
"Keine Vorkenntnisse nötig",
"Schläger und Material werden gestellt",
"Sportkleidung und Hallenschuhe mitbringen",
"3x kostenlos Probetraining",
"Einstieg jederzeit möglich",
).forEach { Text("+ $it", color = Accent700) }
}
}
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
else -> {
val groups = state.config?.training?.zeiten.orEmpty().groupBy(TrainingTimeDto::gruppe)
item { Text("Anfängergruppen", style = MaterialTheme.typography.titleLarge, color = Accent900) }
items(groups.entries.toList()) { group ->
ContentCard(group.key) {
group.value.forEach { Text("${it.tag}, ${it.von} - ${it.bis} Uhr", color = Accent700) }
}
}
item {
Button(
onClick = { navController.navigate(Destinations.Contact.route) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
modifier = Modifier.fillMaxWidth(),
) { Text("Zum Probetraining anmelden") }
}
}
}
}
}
@Composable
private fun Page(
navController: NavController,
showBackNavigation: Boolean,
title: String,
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
}
content()
}
}
@Composable
private fun ContentCard(title: String, content: @Composable () -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun TrainerCard(trainer: TrainerDto) {
ContentCard(trainer.lizenz) {
Text(trainer.name, color = Accent700, fontWeight = FontWeight.SemiBold)
Text("Schwerpunkt: ${trainer.schwerpunkt}", color = Accent500)
trainer.zusatz?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
}
}
@Composable
private fun Loading() {
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
@Composable
private fun ErrorPanel(message: String, retry: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Accent700)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}

View File

@@ -0,0 +1,36 @@
package de.harheimertc.ui.screens.training
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConfigResponse
import de.harheimertc.repositories.TrainingRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class TrainingUiState(
val loading: Boolean = true,
val error: String? = null,
val config: ConfigResponse? = null,
)
@HiltViewModel
class TrainingViewModel @Inject constructor(private val repository: TrainingRepository) : ViewModel() {
private val _state = MutableStateFlow(TrainingUiState())
val state: StateFlow<TrainingUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = TrainingUiState(loading = true)
repository.fetchConfig()
.onSuccess { _state.value = TrainingUiState(loading = false, config = it) }
.onFailure { _state.value = TrainingUiState(loading = false, error = "Trainingsinformationen konnten nicht geladen werden.") }
}
}
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.ui.theme
import androidx.compose.ui.graphics.Color
// Primary (Tailwind)
val Primary50 = Color(0xFFFEF2F2)
val Primary100 = Color(0xFFFEE2E2)
val Primary200 = Color(0xFFFECACA)
val Primary300 = Color(0xFFFCA5A5)
val Primary400 = Color(0xFFF87171)
val Primary500 = Color(0xFFEF4444)
val Primary600 = Color(0xFFDC2626)
val Primary700 = Color(0xFFB91C1C)
val Primary800 = Color(0xFF991B1B)
val Primary900 = Color(0xFF7F1D1D)
// Accent (Tailwind gray-ish palette)
val Accent50 = Color(0xFFFAFAFA)
val Accent100 = Color(0xFFF4F4F5)
val Accent200 = Color(0xFFE4E4E7)
val Accent300 = Color(0xFFD4D4D8)
val Accent400 = Color(0xFFA1A1AA)
val Accent500 = Color(0xFF71717A)
val Accent600 = Color(0xFF52525B)
val Accent700 = Color(0xFF3F3F46)
val Accent800 = Color(0xFF27272A)
val Accent900 = Color(0xFF18181B)
// Other semantic colors
val AppBackground = Color(0xFFFFFFFF)
val AppSurface = Accent50
val AppOnPrimary = Color(0xFFFFFFFF)
val AppError = Color(0xFFB00020)

View File

@@ -0,0 +1,41 @@
package de.harheimertc.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
primary = Primary500,
onPrimary = AppOnPrimary,
primaryContainer = Primary100,
secondary = Accent500,
background = AppBackground,
surface = AppSurface,
error = AppError,
)
private val DarkColors = darkColorScheme(
primary = Primary300,
onPrimary = AppOnPrimary,
primaryContainer = Primary700,
secondary = Accent300,
background = Accent900,
surface = Accent800,
error = AppError,
)
@Composable
fun HarheimerTheme(
darkTheme: Boolean = false,
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
typography = AppTypography,
shapes = MaterialTheme.shapes,
content = content
)
}

View File

@@ -0,0 +1,41 @@
package de.harheimertc.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import de.harheimertc.R
// Bundled variable fonts in res/font:
val InterFamily = FontFamily(Font(R.font.inter_variable))
val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold))
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = MontserratFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 30.sp
),
titleLarge = TextStyle(
fontFamily = MontserratFamily,
fontWeight = FontWeight.Medium,
fontSize = 20.sp
),
bodyLarge = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
bodyMedium = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
),
labelSmall = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

View File

@@ -0,0 +1,37 @@
Fonts für die Android-App
Dieses Verzeichnis soll die gebündelten TTF-Dateien enthalten, damit die App die gleichen Schriften wie die Web-UI verwendet.
Automatischer Download (empfohlen):
1. Ausführbar machen:
```bash
chmod +x android-app/scripts/download-fonts.sh
```
2. Script ausführen:
```bash
./android-app/scripts/download-fonts.sh
```
Das Script lädt die variable Font-Dateien `Inter[opsz,wght].ttf` und `Montserrat[wght].ttf` in `app/src/main/res/font/` und benennt sie in `inter_variable.ttf` und `montserrat_variable.ttf`.
Manuelle Alternative:
- Lade `Inter` und `Montserrat` vom Google Fonts Repo herunter und lege die TTFs in dieses Verzeichnis.
- Benenne die Dateien wie oben.
Compose-Nutzung (Beispiel in Kotlin):
```kotlin
val Inter = FontFamily(
Font(R.font.inter_variable)
)
val Montserrat = FontFamily(
Font(R.font.montserrat_variable)
)
```
Hinweis: Das Herunterladen der Fonts erfolgt von GitHub (Raw URLs). Prüfe die Lizenzen (Google Fonts sind in der Regel OFL-lizenziert).

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary_500">#ef4444</color>
<color name="primary_600">#dc2626</color>
<color name="accent_500">#71717a</color>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HarheimerTC" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">#18181B</item>
<item name="android:navigationBarColor">#18181B</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="membership_documents" path="membership/" />
</paths>

View File

@@ -0,0 +1,16 @@
// Root build.gradle.kts (skeleton)
plugins {
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
id("com.google.dagger.hilt.android") version "2.59.2" apply false
id("com.google.devtools.ksp") version "2.3.7" apply false
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
}
buildscript {
val agp_version by extra("9.2.1")
}
allprojects {
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
# Using AGP 9.2.1 defaults
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
org.gradle.workers.max=2

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android-app/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android-app/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

Binary file not shown.

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
# Lädt Inter Regular und Montserrat SemiBold aus dem Google Fonts GitHub-Repo
# Ziel: android-app/app/src/main/res/font/
OUT_DIR="$(dirname "$0")/../app/src/main/res/font"
mkdir -p "$OUT_DIR"
echo "Download fonts into $OUT_DIR"
# URLs (raw GitHub content)
INTER_URL="https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Regular.ttf"
MONTSERRAT_URL="https://raw.githubusercontent.com/google/fonts/main/ofl/montserrat/Montserrat-SemiBold.ttf"
curl -fL "$INTER_URL" -o "$OUT_DIR/inter_regular.ttf" || { echo "Fehler: Inter konnte nicht heruntergeladen werden."; exit 1; }
curl -fL "$MONTSERRAT_URL" -o "$OUT_DIR/montserrat_semibold.ttf" || { echo "Fehler: Montserrat konnte nicht heruntergeladen werden."; exit 1; }
echo "Fonts heruntergeladen:"
ls -l "$OUT_DIR"/*.ttf
echo "Füge in Android Studio die Fonts als resource-Fonts hinzu oder benutze sie in Compose via R.font.inter_regular."

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "harheimertc-android"
include(":app")

View File

@@ -202,7 +202,7 @@
</p>
</form>
<p class="mt-4 text-sm text-gray-600 text-center">
Ihre Nachricht wird direkt an j.dichmann@gmx.de gesendet
Ihre Nachricht wird an den Vorstand und die Trainer weitergeleitet
</p>
</div>
</div>

View File

@@ -6,6 +6,13 @@
© {{ currentYear }} Harheimer TC 1954 e.V.
</p>
<div class="flex items-center space-x-6 text-sm relative">
<span
v-if="isLoggedIn && appVersion"
class="text-xs text-gray-600"
title="Version"
>
v{{ appVersion }}
</span>
<NuxtLink
to="/impressum"
class="text-gray-400 hover:text-primary-400 transition-colors"
@@ -89,7 +96,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { User, ChevronUp } from 'lucide-vue-next'
@@ -97,11 +104,26 @@ const router = useRouter()
const authStore = useAuthStore()
const currentYear = new Date().getFullYear()
const isMemberMenuOpen = ref(false)
const appVersion = ref('')
// Reactive auth state from store
const isLoggedIn = computed(() => authStore.isLoggedIn)
// const isAdmin = computed(() => authStore.isAdmin)
const loadAppVersion = async () => {
if (!isLoggedIn.value) {
appVersion.value = ''
return
}
try {
const response = await $fetch('/api/app/version')
appVersion.value = response.version || ''
} catch (_error) {
appVersion.value = ''
}
}
const toggleMemberMenu = () => {
isMemberMenuOpen.value = !isMemberMenuOpen.value
}
@@ -116,6 +138,10 @@ onMounted(() => {
authStore.checkAuth()
})
watch(isLoggedIn, () => {
loadAppVersion()
}, { immediate: true })
// Close menu when clicking outside
const handleClickOutside = (event) => {
if (!event.target.closest('.relative')) {

View File

@@ -52,7 +52,7 @@
<!-- Mannschaftsaufstellung -->
<div class="border-t border-gray-200 pt-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Mannschaftsaufstellung Saison 2025/26
Mannschaftsaufstellung Saison {{ selectedSeasonLabel }}
</h3>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
@@ -102,11 +102,36 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { Users } from 'lucide-vue-next'
const props = defineProps({
season: {
type: String,
default: ''
}
})
const mannschaften = ref([])
const getCurrentSeasonSlug = () => {
const now = new Date()
const year = now.getFullYear()
const startYear = now.getMonth() >= 6 ? year : year - 1
const endYear = startYear + 1
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
}
const selectedSeason = computed(() => {
const value = String(props.season || '').trim()
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
})
const selectedSeasonLabel = computed(() => {
const match = String(selectedSeason.value || '').match(/^(\d{2})--(\d{2})$/)
return match ? `20${match[1]}/${match[2]}` : selectedSeason.value
})
async function fetchCsvText(url) {
const attempt = async () => {
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
@@ -125,12 +150,15 @@ async function fetchCsvText(url) {
const loadMannschaften = async () => {
try {
const csv = await fetchCsvText('/api/mannschaften')
const params = new URLSearchParams()
if (selectedSeason.value) params.set('season', selectedSeason.value)
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
// Vereinfachter CSV-Parser
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) {
mannschaften.value = []
return
}
@@ -207,4 +235,8 @@ const formatDate = (dateString) => {
onMounted(() => {
loadMannschaften()
})
watch(selectedSeason, () => {
loadMannschaften()
})
</script>

View File

@@ -109,7 +109,7 @@
</a>
<span class="text-sm text-gray-500">oder</span>
<NuxtLink
to="/satzung"
to="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<Eye

View File

@@ -109,7 +109,7 @@
</a>
<span class="text-sm text-gray-500">oder</span>
<NuxtLink
to="/satzung"
to="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<Eye

View File

@@ -36,7 +36,7 @@
<button
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || route.path.startsWith('/links') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
@click="toggleSubmenu('verein')"
>
Verein
@@ -177,6 +177,13 @@
>
Galerie
</NuxtLink>
<NuxtLink
to="/links"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Links
</NuxtLink>
</template>
<!-- Mannschaften Submenu -->
@@ -299,6 +306,16 @@
Newsletter
</NuxtLink>
</template>
<template v-if="canAccessContactRequests">
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/cms/kontaktanfragen"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Kontaktanfragen
</NuxtLink>
</template>
<template v-if="isAdmin">
<div class="h-3 w-px bg-primary-700" />
<div class="relative inline-block">
@@ -371,6 +388,13 @@
>
Mitgliederverwaltung
</NuxtLink>
<NuxtLink
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Kontaktanfragen
</NuxtLink>
<div class="border-t border-gray-700 my-1" />
<NuxtLink
to="/cms/einstellungen"
@@ -497,6 +521,13 @@
>
Galerie
</NuxtLink>
<NuxtLink
to="/links"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Links
</NuxtLink>
<NuxtLink
to="/newsletter/subscribe"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@@ -707,6 +738,16 @@
Newsletter
</NuxtLink>
</template>
<template v-if="canAccessContactRequests && !isAdmin">
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Kontaktanfragen
</NuxtLink>
</template>
<template v-if="isAdmin">
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink
@@ -744,6 +785,13 @@
>
Mitgliederverwaltung
</NuxtLink>
<NuxtLink
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Kontaktanfragen
</NuxtLink>
<NuxtLink
to="/cms/inhalte"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@@ -825,11 +873,16 @@ const canAccessNewsletter = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
})
const canAccessContactRequests = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
})
// Automatisches Setzen des Submenus basierend auf der Route
const currentSubmenu = computed(() => {
const path = route.path
if (path.startsWith('/verein/') || path.startsWith('/vorstand') ||
path.startsWith('/links') ||
path.startsWith('/vereinsmeisterschaften')) {
return 'verein'
}
@@ -949,7 +1002,7 @@ const toggleSubmenu = (menu) => {
if (menu === 'newsletter' && !path.startsWith('/newsletter')) {
navigateTo('/newsletter/subscribe')
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften')) {
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften') && !path.startsWith('/links')) {
navigateTo('/verein/ueber-uns')
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
navigateTo('/mannschaften')

303
components/cms/CmsLinks.vue Normal file
View File

@@ -0,0 +1,303 @@
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
Links bearbeiten
</h2>
<button
type="button"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
:disabled="saving"
@click="save"
>
{{ saving ? 'Speichert...' : 'Speichern' }}
</button>
</div>
<p class="text-sm text-gray-500 mb-6">
Diese Übersicht wird auf der öffentlichen Seite als Karten dargestellt.
</p>
<div class="space-y-6">
<div
v-for="(section, sectionIndex) in sections"
:key="section.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center gap-3 mb-4">
<input
v-model="section.title"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Block-Titel"
>
<button
type="button"
class="px-3 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeSection(sectionIndex)"
>
Block löschen
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, itemIndex) in section.items"
:key="item.id"
class="grid md:grid-cols-12 gap-2"
>
<input
v-model="item.label"
type="text"
class="md:col-span-4 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Link-Text"
>
<input
v-model="item.href"
type="url"
class="md:col-span-5 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
>
<input
v-model="item.description"
type="text"
class="md:col-span-2 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Beschreibung (optional)"
>
<button
type="button"
class="md:col-span-1 px-2 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeItem(sectionIndex, itemIndex)"
>
X
</button>
</div>
</div>
<div class="mt-3">
<button
type="button"
class="px-3 py-2 text-sm bg-gray-100 text-gray-800 rounded-lg hover:bg-gray-200"
@click="addItem(sectionIndex)"
>
Link hinzufügen
</button>
</div>
</div>
</div>
<div class="mt-6">
<button
type="button"
class="px-4 py-2 text-sm bg-primary-100 text-primary-800 rounded-lg hover:bg-primary-200"
@click="addSection"
>
Block hinzufügen
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const saving = ref(false)
const sections = ref([])
function createId() {
const c = globalThis?.crypto
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const defaultSections = [
{
id: createId(),
title: 'Ergebnisse & Portale',
items: [
{ id: createId(), label: 'MyTischtennis.de', href: 'http://www.mytischtennis.de/public/home', description: '(offizielle QTTR-Werte)' },
{ id: createId(), label: 'Click-tt Ergebnisse', href: 'http://httv.click-tt.de/', description: '(offizieller Ergebnisdienst HTTV)' },
{ id: createId(), label: 'Tischtennis Pur - das Tischtennis Portal', href: 'https://www.tischtennis-pur.de/', description: '(Informationen, Blogs, Fachbeiträge, Tipps)' },
{ id: createId(), label: 'Liveticker 2. und 3. TT-Bundesliga', href: 'https://ticker.tt-news.com/', description: '' }
]
},
{
id: createId(),
title: 'Verbände',
items: [
{ id: createId(), label: 'Hessischer Tischtennisverband (HTTV)', href: 'http://www.httv.de/', description: '' },
{ id: createId(), label: 'Deutscher Tischtennisbund (DTTB)', href: 'http://www.tischtennis.de/aktuelles/', description: '' },
{ id: createId(), label: 'European Table Tennis Union (ETTU)', href: 'http://www.ettu.org/', description: '' },
{ id: createId(), label: 'International Table Tennis Federation (ITTF)', href: 'https://www.ittf.com/', description: '' }
]
},
{
id: createId(),
title: 'Regionale Links',
items: [
{ id: createId(), label: 'Stadt Frankfurt', href: 'http://www.frankfurt.de/', description: '' },
{ id: createId(), label: 'Vereinsring Harheim', href: 'http://www.harheim.com/', description: '' }
]
},
{
id: createId(),
title: 'Partner & Vereine',
items: [
{ id: createId(), label: 'TTC OE Bad Homburg', href: 'http://www.ttcoe.de/', description: '' },
{ id: createId(), label: 'SpVgg Steinkirchen e.V.', href: 'https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis', description: '' },
{ id: createId(), label: 'Ergebnisse SpVgg Steinkirchen', href: 'https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/', description: '' }
]
}
]
function createHtmlFromSections(inputSections) {
const safeSections = Array.isArray(inputSections) ? inputSections : []
return safeSections
.filter((s) => s.title && Array.isArray(s.items) && s.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item.label && item.href)
.map((item) => {
const description = item.description ? ` ${item.description}` : ''
return `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${description}</li>`
})
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections) || rawSections.length === 0) {
return JSON.parse(JSON.stringify(defaultSections))
}
return rawSections.map((section) => ({
id: section.id || createId(),
title: section.title || '',
items: Array.isArray(section.items)
? section.items.map((item) => ({
id: item.id || createId(),
label: item.label || '',
href: item.href || '',
description: item.description || ''
}))
: []
}))
}
function stripTags(html) {
if (!html) return ''
return String(html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.trim()
}
function parseLinksHtml(html) {
if (!html || typeof html !== 'string') return []
const sectionsParsed = []
const sectionPattern = /<h2[^>]*>([\s\S]*?)<\/h2>\s*<ul[^>]*>([\s\S]*?)<\/ul>/gi
let sectionMatch
while ((sectionMatch = sectionPattern.exec(html)) !== null) {
const title = stripTags(sectionMatch[1])
const ulContent = sectionMatch[2] || ''
const itemPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi
const items = []
let itemMatch
while ((itemMatch = itemPattern.exec(ulContent)) !== null) {
const liHtml = itemMatch[1] || ''
const anchorMatch = liHtml.match(/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
const href = anchorMatch ? String(anchorMatch[1]).trim() : ''
const label = anchorMatch ? stripTags(anchorMatch[2]) : stripTags(liHtml)
let description = ''
if (anchorMatch) {
description = stripTags(liHtml.replace(anchorMatch[0], ''))
}
if (label || href || description) {
items.push({
id: createId(),
label,
href,
description
})
}
}
if (title || items.length > 0) {
sectionsParsed.push({
id: createId(),
title,
items
})
}
}
return sectionsParsed
}
function addSection() {
sections.value.push({
id: createId(),
title: '',
items: [{ id: createId(), label: '', href: '', description: '' }]
})
}
function removeSection(index) {
sections.value.splice(index, 1)
}
function addItem(sectionIndex) {
sections.value[sectionIndex].items.push({
id: createId(),
label: '',
href: '',
description: ''
})
}
function removeItem(sectionIndex, itemIndex) {
sections.value[sectionIndex].items.splice(itemIndex, 1)
}
async function load() {
try {
const current = await $fetch('/api/config')
const configured = current?.seiten?.linksStructured
if (Array.isArray(configured) && configured.length > 0) {
sections.value = normalizeSections(configured)
return
}
const html = current?.seiten?.links
const parsed = parseLinksHtml(html)
sections.value = normalizeSections(parsed)
} catch {
sections.value = JSON.parse(JSON.stringify(defaultSections))
}
}
async function save() {
saving.value = true
try {
const current = await $fetch('/api/config')
const cleanedSections = normalizeSections(sections.value)
const linksHtml = createHtmlFromSections(cleanedSections)
const updated = {
...current,
seiten: {
...(current?.seiten || {}),
linksStructured: cleanedSections,
links: linksHtml
}
}
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch (_e) { /* no-op */ }
} catch (error) {
const msg = error?.data?.message || 'Fehler beim Speichern der Links'
try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch (_e) { /* no-op */ }
} finally {
saving.value = false
}
}
onMounted(load)
</script>

View File

@@ -1,40 +1,136 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<div class="flex justify-between items-center mb-6 gap-4 flex-wrap">
<div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
Mannschaften verwalten
</h2>
<div class="w-24 h-1 bg-primary-600" />
</div>
<button class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen
</button>
<div class="flex items-center gap-2 flex-wrap">
<label class="text-sm font-medium text-gray-700">Saison</label>
<select
v-model="selectedSeason"
class="px-3 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isLoading || isSaving || isCreatingSeason"
@change="onSeasonChange"
>
<option
v-for="season in seasons"
:key="season"
:value="season"
>
{{ formatSeasonLabel(season) }}
</option>
</select>
<button
class="flex items-center px-4 py-2 bg-white border border-primary-300 text-primary-700 hover:bg-primary-50 font-semibold rounded-lg transition-colors disabled:opacity-50"
:disabled="isLoading || isSaving || isCreatingSeason"
@click="createNextSeason"
>
<Loader2
v-if="isCreatingSeason"
:size="18"
class="animate-spin mr-2"
/>
<Plus
v-else
:size="18"
class="mr-2"
/>
Neue Saison anlegen
</button>
<button
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<Plus
:size="20"
class="mr-2"
/> Mannschaft hinzufügen
</button>
</div>
</div>
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div>
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Liga
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Staffelleiter
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaftsführer
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Spieler
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td>
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td>
<tr
v-for="(mannschaft, index) in mannschaften"
:key="index"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ mannschaft.mannschaft }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.liga }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.staffelleiter }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.mannschaftsfuehrer }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
<div class="max-w-xs truncate">
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button>
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button>
<button
class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(mannschaft, index)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(mannschaft, index)"
>
<Trash2 :size="18" />
</button>
</td>
</tr>
</tbody>
@@ -42,86 +138,248 @@
</div>
</div>
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center">
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3>
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p>
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button>
<div
v-if="!isLoading && mannschaften.length === 0"
class="bg-white rounded-xl shadow-lg p-12 text-center"
>
<Users
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine Mannschaften vorhanden
</h3>
<p class="text-gray-600 mb-6">
Fügen Sie die erste Mannschaft hinzu.
</p>
<button
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
Mannschaft hinzufügen
</button>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
<div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200">
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2>
<h2 class="text-2xl font-display font-bold text-gray-900">
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
</h2>
</div>
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft">
<form
class="p-6 space-y-4"
@submit.prevent="saveMannschaft"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
<input v-model="formData.mannschaft" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.mannschaft"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
<input v-model="formData.liga" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.liga"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
<input v-model="formData.staffelleiter" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.staffelleiter"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
<input v-model="formData.telefon" type="tel" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.telefon"
type="tel"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
<input v-model="formData.heimspieltag" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.heimspieltag"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
<input v-model="formData.spielsystem" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.spielsystem"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
<input v-model="formData.mannschaftsfuehrer" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.mannschaftsfuehrer"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
<div class="space-y-2">
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div>
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white">
<div
v-if="formData.spielerListe.length === 0"
class="text-sm text-gray-500"
>
Noch keine Spieler eingetragen.
</div>
<div
v-for="(spieler, index) in formData.spielerListe"
:key="spieler.id"
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
>
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving">
<input
v-model="spieler.name"
type="text"
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Spielername"
:disabled="isSaving"
>
<div class="flex items-center gap-1">
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button>
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button>
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button>
<button
type="button"
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach oben"
:disabled="isSaving || index === 0"
@click="moveSpielerUp(index)"
>
<ChevronUp :size="18" />
</button>
<button
type="button"
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach unten"
:disabled="isSaving || index === formData.spielerListe.length - 1"
@click="moveSpielerDown(index)"
>
<ChevronDown :size="18" />
</button>
<button
type="button"
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Spieler entfernen"
:disabled="isSaving"
@click="removeSpieler(spieler.id)"
>
<Trash2 :size="18" />
</button>
</div>
<div class="flex items-center gap-2">
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen">
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option>
<select
v-model="moveTargetBySpielerId[spieler.id]"
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
title="Mannschaft auswählen"
>
<option
v-for="t in mannschaftenSelectOptions"
:key="t"
:value="t"
>
{{ t }}
</option>
</select>
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button>
<button
type="button"
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
title="In ausgewählte Mannschaft verschieben"
@click="moveSpielerToMannschaft(spieler.id)"
>
<ArrowRight :size="18" />
</button>
</div>
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between">
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button>
<p class="text-xs text-gray-500">Reihenfolge per / ändern.</p>
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
:disabled="isSaving"
@click="addSpieler()"
>
<Plus
:size="18"
class="mr-2"
/> Spieler hinzufügen
</button>
<p class="text-xs text-gray-500">
Reihenfolge per / ändern.
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving">
<input
v-model="formData.weitere_informationen_link"
type="url"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
:disabled="isSaving"
>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/> {{ errorMessage }}
</div>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"><AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}</div>
<div class="flex justify-end space-x-4 pt-4">
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"><Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span></button>
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
@@ -135,13 +393,17 @@ import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDo
const isLoading = ref(true)
const isSaving = ref(false)
const isCreatingSeason = ref(false)
const mannschaften = ref([])
const seasons = ref([])
const selectedSeason = ref('')
const showModal = ref(false)
const errorMessage = ref('')
const isEditing = ref(false)
const editingIndex = ref(-1)
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
const moveTargetBySpielerId = ref({})
const initialMoveTargetBySpielerId = ref({})
const pendingSpielerNamesByTeamIndex = ref({})
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
@@ -155,13 +417,52 @@ async function fetchCsvText(url) {
try { return await attempt() } catch { await new Promise(r => setTimeout(r, 150)); return await attempt() }
}
function getMannschaftenFilenameForSeason(seasonSlug = '') {
const season = String(seasonSlug || '').trim()
return season ? `mannschaften_${season}.csv` : 'mannschaften.csv'
}
function formatSeasonLabel(seasonSlug) {
const match = String(seasonSlug || '').match(/^(\d{2})--(\d{2})$/)
return match ? `20${match[1]}/${match[2]}` : String(seasonSlug || '')
}
function getNextSeasonSlug(seasonSlug) {
const match = String(seasonSlug || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return ''
const start = Number(match[1])
const nextStart = (start + 1) % 100
const nextEnd = (nextStart + 1) % 100
return `${String(nextStart).padStart(2, '0')}--${String(nextEnd).padStart(2, '0')}`
}
async function loadSeasons() {
try {
const res = await fetch('/api/mannschaften/seasons')
const result = await res.json()
if (!result?.success || !Array.isArray(result.seasons) || result.seasons.length === 0) {
seasons.value = [result?.currentSeason || '']
selectedSeason.value = seasons.value[0] || ''
return
}
seasons.value = result.seasons
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
selectedSeason.value = result.defaultSeason || seasons.value[0]
}
} catch {
if (!seasons.value.length) seasons.value = ['']
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
}
}
const mannschaftenSelectOptions = computed(() => {
const current = (formData.value.mannschaft || '').trim()
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
return [...new Set([current, ...names])].filter(Boolean)
})
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; initialMoveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
function getPendingSpielerNamesForTeamIndex(teamIndex) {
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
@@ -171,7 +472,9 @@ function getPendingSpielerNamesForTeamIndex(teamIndex) {
const loadMannschaften = async () => {
isLoading.value = true
try {
const csv = await fetchCsvText('/api/mannschaften')
const params = new URLSearchParams()
if (selectedSeason.value) params.set('season', selectedSeason.value)
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) { mannschaften.value = []; return }
mannschaften.value = lines.slice(1).map(line => {
@@ -184,6 +487,10 @@ const loadMannschaften = async () => {
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
}
const onSeasonChange = async () => {
await loadMannschaften().catch(() => {})
}
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
@@ -191,29 +498,60 @@ const openEditModal = (mannschaft, index) => {
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
const currentTeam = (formData.value.mannschaft || '').trim()
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
for (const s of formData.value.spielerListe) {
moveTargetBySpielerId.value[s.id] = currentTeam
initialMoveTargetBySpielerId.value[s.id] = currentTeam
}
}
const addSpieler = () => {
const item = newSpielerItem('')
const currentTeam = (formData.value.mannschaft || '').trim()
formData.value.spielerListe.push(item)
moveTargetBySpielerId.value[item.id] = currentTeam
initialMoveTargetBySpielerId.value[item.id] = currentTeam
}
const removeSpieler = (id) => {
const idx = formData.value.spielerListe.findIndex(s => s.id === id)
if (idx === -1) return
formData.value.spielerListe.splice(idx, 1)
delete moveTargetBySpielerId.value[id]
delete initialMoveTargetBySpielerId.value[id]
}
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
const canMoveSpieler = (id) => {
const target = (moveTargetBySpielerId.value[id] || '').trim()
const initialTarget = (initialMoveTargetBySpielerId.value[id] || '').trim()
return Boolean(target) && Boolean(initialTarget) && target !== initialTarget
}
const moveSpielerToMannschaft = (spielerId) => {
if (!isEditing.value || editingIndex.value < 0) return
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
if (!isEditing.value || editingIndex.value < 0) return false
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return false
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return false }
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return false
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return false }
formData.value.spielerListe.splice(idx, 1)
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
delete moveTargetBySpielerId.value[spielerId]
delete initialMoveTargetBySpielerId.value[spielerId]
return true
}
const applySelectedSpielerTransfers = () => {
if (!isEditing.value || editingIndex.value < 0) return true
const pendingIds = formData.value.spielerListe
.filter(spieler => canMoveSpieler(spieler.id))
.map(spieler => spieler.id)
return pendingIds.every(spielerId => moveSpielerToMannschaft(spielerId))
}
const saveMannschaft = async () => {
isSaving.value = true; errorMessage.value = ''
try {
if (!applySelectedSpielerTransfers()) return
const spielerString = serializeSpielerList(formData.value.spielerListe)
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
@@ -230,7 +568,59 @@ const saveCSV = async () => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',')
})
await $fetch('/api/cms/save-csv', { method: 'POST', body: { filename: 'mannschaften.csv', content: [header, ...rows].join('\n') } })
await $fetch('/api/cms/save-csv', {
method: 'POST',
body: {
filename: getMannschaftenFilenameForSeason(selectedSeason.value),
content: [header, ...rows].join('\n')
}
})
}
const createNextSeason = async () => {
const baseSeason = selectedSeason.value || seasons.value[0]
const nextSeason = getNextSeasonSlug(baseSeason)
if (!nextSeason) {
errorMessage.value = 'Naechste Saison konnte nicht ermittelt werden.'
return
}
if (seasons.value.includes(nextSeason)) {
selectedSeason.value = nextSeason
await loadMannschaften().catch(() => {})
return
}
isCreatingSeason.value = true
errorMessage.value = ''
try {
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
const rows = mannschaften.value.map(m => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung || nowIsoDate())].join(',')
})
await $fetch('/api/cms/save-csv', {
method: 'POST',
body: {
filename: getMannschaftenFilenameForSeason(nextSeason),
content: [header, ...rows].join('\n')
}
})
await loadSeasons()
selectedSeason.value = nextSeason
await loadMannschaften().catch(() => {})
if (window.showSuccessModal) {
window.showSuccessModal('Erfolg', `Neue Saison ${formatSeasonLabel(nextSeason)} wurde angelegt.`)
}
} catch (error) {
console.error('Fehler beim Anlegen der Saison:', error)
errorMessage.value = error?.data?.statusMessage || error?.statusMessage || 'Saison konnte nicht angelegt werden.'
if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value)
} finally {
isCreatingSeason.value = false
}
}
const confirmDelete = (mannschaft, index) => {
@@ -241,5 +631,8 @@ const confirmDelete = (mannschaft, index) => {
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
}
onMounted(() => { loadMannschaften().catch(() => {}) })
onMounted(async () => {
await loadSeasons()
await loadMannschaften().catch(() => {})
})
</script>

View File

@@ -53,6 +53,17 @@
</div>
</div>
<div class="mb-4 flex items-center">
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
v-model="filterHasHallKey"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
Nur mit Hallenschlüssel
</label>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
@@ -85,6 +96,9 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
🔑
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
@@ -98,7 +112,7 @@
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="member in members"
v-for="member in filteredMembers"
:key="member.id"
class="hover:bg-gray-50"
>
@@ -177,6 +191,15 @@
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="member.hasHallKey"
class="text-lg text-amber-600"
title="Hat Hallenschlüssel"
>
🔑
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span
@@ -227,7 +250,7 @@
</div>
<div
v-if="members.length === 0"
v-if="filteredMembers.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
@@ -240,7 +263,7 @@
class="space-y-4"
>
<div
v-for="member in members"
v-for="member in filteredMembers"
:key="member.id"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
>
@@ -250,6 +273,13 @@
<h3 class="text-xl font-semibold text-gray-900">
{{ member.name }}
</h3>
<span
v-if="member.hasHallKey"
class="ml-2 text-amber-600"
title="Hat Hallenschlüssel"
>
🔑
</span>
<span
v-if="member.hasLogin"
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
@@ -389,7 +419,7 @@
</div>
<div
v-if="members.length === 0"
v-if="filteredMembers.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
@@ -439,12 +469,12 @@
<input
v-model="formData.geburtsdatum"
type="date"
required
:required="isBirthdateRequired"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
<p class="text-xs text-gray-500 mt-1">
Wird zur eindeutigen Identifizierung benötigt
Für neue Mitglieder erforderlich. Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
</p>
</div>
@@ -504,6 +534,22 @@
</label>
</div>
<div class="flex items-center">
<input
id="hasHallKey"
v-model="formData.hasHallKey"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving"
>
<label
for="hasHallKey"
class="ml-2 block text-sm font-medium text-gray-700"
>
Hat Hallenschlüssel
</label>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
@@ -624,10 +670,18 @@
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Vorname</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Vorname
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Nachname
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Geburtsdatum
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
E-Mail
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@@ -636,10 +690,18 @@
:key="index"
class="hover:bg-gray-50"
>
<td class="px-3 py-2">{{ row.firstName || '-' }}</td>
<td class="px-3 py-2">{{ row.lastName || '-' }}</td>
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td>
<td class="px-3 py-2">{{ row.email || '-' }}</td>
<td class="px-3 py-2">
{{ row.firstName || '-' }}
</td>
<td class="px-3 py-2">
{{ row.lastName || '-' }}
</td>
<td class="px-3 py-2">
{{ row.geburtsdatum || '-' }}
</td>
<td class="px-3 py-2">
{{ row.email || '-' }}
</td>
</tr>
</tbody>
</table>
@@ -658,35 +720,65 @@
class="mb-6"
>
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-3">
Import-Ergebnisse
</h3>
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div>
<div class="text-sm text-gray-600">Importiert</div>
<div class="text-2xl font-bold text-green-600">
{{ bulkImportResults.summary.imported }}
</div>
<div class="text-sm text-gray-600">
Importiert
</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div>
<div class="text-sm text-gray-600">Duplikate</div>
<div class="text-2xl font-bold text-yellow-600">
{{ bulkImportResults.summary.duplicates }}
</div>
<div class="text-sm text-gray-600">
Duplikate
</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div>
<div class="text-sm text-gray-600">Fehler</div>
<div class="text-2xl font-bold text-red-600">
{{ bulkImportResults.summary.errors }}
</div>
<div class="text-sm text-gray-600">
Fehler
</div>
</div>
</div>
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4>
<div
v-if="bulkImportResults.results.duplicates.length > 0"
class="mt-4"
>
<h4 class="text-sm font-medium text-gray-700 mb-2">
Duplikate:
</h4>
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="dup in bulkImportResults.results.duplicates" :key="dup.index">
<div
v-for="dup in bulkImportResults.results.duplicates"
:key="dup.index"
>
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
</div>
</div>
</div>
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4>
<div
v-if="bulkImportResults.results.errors.length > 0"
class="mt-4"
>
<h4 class="text-sm font-medium text-gray-700 mb-2">
Fehler:
</h4>
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="err in bulkImportResults.results.errors" :key="err.index">
<div
v-for="err in bulkImportResults.results.errors"
:key="err.index"
>
Zeile {{ err.index }}: {{ err.error }}
</div>
</div>
@@ -734,6 +826,7 @@ const showModal = ref(false)
const editingMember = ref(null)
const errorMessage = ref('')
const viewMode = ref('cards')
const filterHasHallKey = ref(false)
// Bulk import state
const showBulkImportModal = ref(false)
@@ -752,7 +845,8 @@ const formData = ref({
phone: '',
address: '',
notes: '',
isMannschaftsspieler: false
isMannschaftsspieler: false,
hasHallKey: false
})
const canEdit = computed(() => {
@@ -763,6 +857,15 @@ const canViewContactData = computed(() => {
return authStore.hasRole('vorstand')
})
const isBirthdateRequired = computed(() => {
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
})
const filteredMembers = computed(() => {
if (!filterHasHallKey.value) return members.value
return members.value.filter(member => member.hasHallKey)
})
const loadMembers = async () => {
isLoading.value = true
try {
@@ -777,7 +880,7 @@ const loadMembers = async () => {
const openAddModal = () => {
editingMember.value = null
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false }
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false }
showModal.value = true
errorMessage.value = ''
}
@@ -792,7 +895,8 @@ const openEditModal = (member) => {
phone: member.phone || '',
address: member.address || '',
notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true
isMannschaftsspieler: member.isMannschaftsspieler === true,
hasHallKey: member.hasHallKey === true
}
showModal.value = true
errorMessage.value = ''

View File

@@ -105,15 +105,23 @@
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
<h4 class="text-sm font-medium text-gray-900 mb-2">
Kontaktdaten
</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
<p v-if="application.personalData.telefon_privat"><strong>Telefon:</strong> {{ application.personalData.telefon_privat }}</p>
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p>
<p v-if="application.personalData.telefon_privat">
<strong>Telefon:</strong> {{ application.personalData.telefon_privat }}
</p>
<p v-if="application.personalData.telefon_mobil">
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
</p>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
<h4 class="text-sm font-medium text-gray-900 mb-2">
Antragsdetails
</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
@@ -141,8 +149,18 @@
class="text-gray-400 hover:text-gray-600"
@click="closeModal"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -151,16 +169,24 @@
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
<h3 class="text-lg font-medium text-gray-900 mb-4">
Persönliche Daten
</h3>
<div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
<p v-if="selectedApplication.personalData.telefon_privat"><strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}</p>
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p>
<p v-if="selectedApplication.personalData.telefon_privat">
<strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}
</p>
<p v-if="selectedApplication.personalData.telefon_mobil">
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
</p>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
<h3 class="text-lg font-medium text-gray-900 mb-4">
Antragsdetails
</h3>
<div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
@@ -172,14 +198,29 @@
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeModal">Schließen</button>
<button
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeModal"
>
Schließen
</button>
<button
v-if="selectedApplication.metadata.pdfGenerated"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
@click="downloadPDF(selectedApplication.id)"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF herunterladen
</button>

View File

@@ -2,112 +2,335 @@
<div>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2>
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
Spielpläne bearbeiten
</h2>
<div class="space-x-3">
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
@click="showUploadModal = true"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/></svg>
CSV hochladen
</button>
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button>
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div>
</div>
<!-- CSV Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3>
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Vereins-Spielplan (CSV)
</h3>
<div
v-if="currentFile"
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div>
<svg
class="w-5 h-5 text-green-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg>
<div>
<p class="text-sm font-medium text-green-800">
{{ currentFile.name }}
</p><p class="text-xs text-green-600">
{{ currentFile.size }} bytes
</p>
</div>
</div>
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button>
<button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="removeFile"
>
Entfernen
</button>
</div>
</div>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer" :class="{ 'border-primary-400 bg-primary-50': isDragOver }" @click="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p>
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p>
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
@click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/></svg>
<p class="text-lg font-medium text-gray-900 mb-2">
CSV-Datei hochladen
</p>
<p class="text-sm text-gray-600 mb-4">
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
</p>
</div>
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect">
<input
ref="fileInput"
type="file"
accept=".csv"
class="hidden"
@change="handleFileSelect"
>
</div>
<!-- Column Selection -->
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3>
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p>
<div
v-if="csvData.length > 0 && !columnsSelected"
class="bg-white rounded-xl shadow-lg p-6 mb-8"
>
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Spalten auswählen
</h3>
<p class="text-sm text-gray-600 mb-6">
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
</p>
<div class="space-y-4">
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50">
<div
v-for="(header, index) in csvHeaders"
:key="index"
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded">
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label>
<input
:id="`column-${index}`"
v-model="selectedColumns[index]"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<label
:for="`column-${index}`"
class="ml-3 text-sm font-medium text-gray-900"
>{{ header }}</label>
</div>
<div class="text-xs text-gray-500">
{{ getColumnPreview(index) }}
</div>
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
</div>
</div>
<div class="mt-6 flex justify-between items-center">
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div>
<div class="text-sm text-gray-600">
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
</div>
<div class="space-x-3">
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button>
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button>
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button>
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button>
<button
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="selectAllColumns"
>
Alle auswählen
</button>
<button
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="deselectAllColumns"
>
Alle abwählen
</button>
<button
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="suggestHalleColumns"
>
Halle-Spalten vorschlagen
</button>
<button
:disabled="selectedColumnsCount === 0"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="confirmColumnSelection"
>
Auswahl bestätigen
</button>
</div>
</div>
</div>
<!-- Data Preview -->
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6">
<div
v-if="csvData.length > 0 && columnsSelected"
class="bg-white rounded-xl shadow-lg p-6"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3>
<h3 class="text-xl font-semibold text-gray-900">
Datenvorschau
</h3>
<div class="flex space-x-2">
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button>
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button>
<button
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="exportCSV"
>
CSV exportieren
</button>
<button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="clearData"
>
Daten löschen
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead>
<thead class="bg-gray-50">
<tr>
<th
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
:key="index"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ header }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td>
<tr
v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div>
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div>
<div
v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
class="mt-4 text-center text-sm text-gray-600"
>
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
</div>
<div class="mt-4 text-sm text-gray-600">
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
</div>
</div>
<!-- Empty State -->
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<p class="text-gray-600">Keine CSV-Daten geladen.</p>
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p>
<div
v-if="csvData.length === 0"
class="text-center py-12 bg-white rounded-xl shadow-lg"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/></svg>
<p class="text-gray-600">
Keine CSV-Daten geladen.
</p>
<p class="text-sm text-gray-500 mt-2">
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
</p>
</div>
<!-- Upload Modal -->
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal">
<div
v-if="showUploadModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeUploadModal"
>
<div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
CSV-Datei hochladen
</h3>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div>
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div>
<div class="bg-blue-50 p-4 rounded-lg"><h4 class="text-sm font-medium text-blue-800 mb-2">Erwartetes CSV-Format:</h4><div class="text-xs text-blue-700 space-y-1"><p> Erste Zeile: Spaltenüberschriften</p><p> Trennzeichen: Komma (,)</p></div></div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input
ref="modalFileInput"
type="file"
accept=".csv"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@change="handleModalFileSelect"
>
</div>
<div
v-if="selectedFile"
class="p-3 bg-gray-50 rounded-lg"
>
<p class="text-sm text-gray-700">
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
</p><p class="text-xs text-gray-500">
{{ selectedFile.size }} bytes
</p>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-blue-800 mb-2">
Erwartetes CSV-Format:
</h4><div class="text-xs text-blue-700 space-y-1">
<p> Erste Zeile: Spaltenüberschriften</p><p> Trennzeichen: Komma (,)</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button>
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button>
<button
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeUploadModal"
>
Abbrechen
</button>
<button
:disabled="!selectedFile"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="processSelectedFile"
>
Hochladen
</button>
</div>
</div>
</div>
<!-- Processing Modal -->
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
v-if="isProcessing"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3>
<p class="text-sm text-gray-600">{{ processingMessage }}</p>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
Verarbeitung läuft...
</h3>
<p class="text-sm text-gray-600">
{{ processingMessage }}
</p>
</div>
</div>
</div>
@@ -174,7 +397,11 @@ const save = async () => {
try {
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
const response = await fetch('/api/cms/save-csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'spielplan.csv', content: csv }) })
if (response.ok) alert('Spielplan erfolgreich gespeichert!'); else alert('Fehler beim Speichern!')
if (response.ok) {
const result = await response.json()
const seasonFile = result.jsonWrittenTo?.[0]?.split('/').pop()
alert(seasonFile ? `Spielplan erfolgreich als ${seasonFile} gespeichert!` : 'Spielplan erfolgreich gespeichert!')
} else alert('Fehler beim Speichern!')
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
}
@@ -183,11 +410,12 @@ const closeUploadModal = () => { showUploadModal.value = false; selectedFile.val
onMounted(() => {
(async () => {
try {
const response = await fetch('/data/spielplan.csv'); if (!response.ok) return; const text = await response.text()
const lines = text.split('\n').filter(line => line.trim() !== ''); if (lines.length < 2) return
const parseCSVLine = (line) => { const values = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }; values.push(current.trim()); return values }
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
currentFile.value = { name: 'spielplan.csv', size: text.length, lastModified: null }
const response = await fetch('/api/spielplan'); if (!response.ok) return
const result = await response.json(); if (!result.success || !Array.isArray(result.headers) || !Array.isArray(result.data)) return
csvHeaders.value = result.headers
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null }
} catch { /* ignore */ }
})()
})

View File

@@ -11,36 +11,72 @@
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<Plus :size="20" class="mr-2" />
<Plus
:size="20"
class="mr-2"
/>
Termin hinzufügen
</button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<Loader2 :size="40" class="animate-spin text-primary-600" />
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Termine Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Datum
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Uhrzeit
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Titel
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Beschreibung
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kategorie
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50">
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td>
<tr
v-for="termin in termine"
:key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(termin.datum) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ termin.uhrzeit || '-' }}
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ termin.titel }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ termin.beschreibung || '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
:class="{
@@ -54,56 +90,140 @@
>{{ termin.kategorie }}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button>
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button>
<button
class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(termin)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(termin)"
>
<Trash2 :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div>
<div
v-if="termine.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Termine vorhanden.
</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
<div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2>
<form class="space-y-4" @submit.prevent="saveTermin">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
</h2>
<form
class="space-y-4"
@submit.prevent="saveTermin"
>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
<input v-model="formData.datum" type="date" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.datum"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
<input v-model="formData.uhrzeit" type="time" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.uhrzeit"
type="time"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<select v-model="formData.kategorie" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<option value="Training">Training</option>
<option value="Punktspiel">Punktspiel</option>
<option value="Turnier">Turnier</option>
<option value="Veranstaltung">Veranstaltung</option>
<option value="Sonstiges">Sonstiges</option>
<select
v-model="formData.kategorie"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
<option value="Training">
Training
</option>
<option value="Punktspiel">
Punktspiel
</option>
<option value="Turnier">
Turnier
</option>
<option value="Veranstaltung">
Veranstaltung
</option>
<option value="Sonstiges">
Sonstiges
</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
<input v-model="formData.titel" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<input
v-model="formData.titel"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea v-model="formData.beschreibung" rows="3" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving" />
<textarea
v-model="formData.beschreibung"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
/>
</div>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/> {{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving">
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" />
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>

View File

@@ -18,36 +18,108 @@
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
<!-- Formatierung -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('bold')"
>
<strong>B</strong>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('italic')"
>
<em>I</em>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(1)"
>
H1
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(2)"
>
H2
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(3)"
>
H3
</button>
</div>
<!-- Listen -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')"></button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
</div>
<!-- Schnellzugriff für Regeln -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
@click="insertRuleTemplate('generic')"
>
Neue Regel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
@click="insertRuleTemplate('basic')"
>
Grundregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
@click="insertRuleTemplate('penalty')"
>
Strafregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
@click="insertRuleTemplate('service')"
>
Aufschlag
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
@click="deleteCurrentRule()"
>
Regel löschen
</button>
</div>
<!-- Weitere Tools -->
<div class="flex items-center gap-1">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="createLink()"
>
Link
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="removeFormat()"
>
Clear
</button>
</div>
</div>
</div>
<!-- Hilfe-Sektion -->
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3>
<h3 class="text-lg font-semibold text-blue-900 mb-2">
So arbeiten Sie mit Regel-Kästchen:
</h3>
<div class="text-sm text-blue-800 space-y-2">
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
<ul class="ml-4 space-y-1">

View File

@@ -54,6 +54,108 @@ has_tracked_files_under() {
git ls-files "$prefix" | head -n 1 | grep -q .
}
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
fi
}
install_dependencies_if_needed() {
local cache_dir=".deploy-cache"
local lock_hash_file="$cache_dir/package-lock.sha256"
local current_lock_hash=""
local previous_lock_hash=""
if [ ! -f "package-lock.json" ]; then
echo " package-lock.json fehlt, führe npm install aus..."
install_dependencies
return 0
fi
mkdir -p "$cache_dir"
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
if [ -f "$lock_hash_file" ]; then
previous_lock_hash="$(cat "$lock_hash_file" 2>/dev/null || true)"
fi
if [ ! -d "node_modules" ]; then
echo " node_modules fehlt, installiere Dependencies..."
install_dependencies
elif [ "$current_lock_hash" != "$previous_lock_hash" ]; then
echo " package-lock.json geändert, führe npm ci aus..."
install_dependencies
else
echo " package-lock.json unverändert, überspringe npm ci"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh"
if [ -f ".nvmrc" ]; then
echo " Using Node version from .nvmrc..."
nvm use
fi
fi
}
ensure_node_version() {
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js ist nicht im PATH."
exit 1
fi
local node_version
node_version="$(node -p 'process.versions.node')"
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
echo " Node.js $node_version"
}
sync_public_documents_to_build() {
if [ ! -d "public/documents" ]; then
echo " No public/documents directory to sync"
return 0
fi
if [ ! -d ".output/public" ]; then
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
exit 1
fi
mkdir -p ".output/public/documents"
cp -a "public/documents/." ".output/public/documents/"
echo " ✓ public/documents -> .output/public/documents synchronisiert"
local template_pdf="beitrittserklärung_template.pdf"
if [ -f "public/documents/$template_pdf" ]; then
local source_size output_size
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
exit 1
fi
echo "$template_pdf im Build verifiziert ($output_size bytes)"
fi
}
echo "0. Ensuring persistent data directories (recommended)..."
# IMPORTANT: Only symlink server/data if it's not tracked by git.
if has_tracked_files_under "server/data"; then
@@ -100,6 +202,18 @@ if ls public/data/*.csv >/dev/null 2>&1; then
else
echo " No public CSVs to backup (public/data/*.csv not found)"
fi
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
elif ls public/data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
else
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
fi
# 2. Handle local changes and Git Pull
echo "2. Handling local changes and pulling latest from git..."
@@ -125,7 +239,20 @@ git clean -fd \
# Pull latest changes
echo " Pulling latest changes..."
git pull
git fetch origin main
git checkout -B main origin/main
if ! git reset --hard origin/main; then
echo "ERROR: git pull fehlgeschlagen."
echo ""
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
echo "Prüfen:"
echo " ssh -T git@tsschulz.de"
echo ""
echo "Optional auf HTTPS wechseln:"
echo " git remote set-url origin https://tsschulz.de/<owner>/<repo>.git"
echo "Oder SSH-Key für User $(id -un) hinterlegen."
exit 1
fi
# Reset any accidental changes from stash restore (should be none now)
git reset --hard HEAD >/dev/null 2>&1
@@ -141,7 +268,9 @@ fi
# 3. Install dependencies
echo ""
echo "3. Installing dependencies..."
npm install
use_project_node
ensure_node_version
install_dependencies_if_needed
# 4. Remove old build (but keep data!)
echo ""
@@ -164,18 +293,23 @@ if [ -d ".output" ]; then
echo " ✓ .output gelöscht"
fi
# Auch .nuxt Cache löschen für sauberen Build
if [ -d ".nuxt" ]; then
echo " Removing .nuxt cache..."
rm -rf .nuxt
echo " ✓ .nuxt gelöscht"
# .nuxt standardmäßig behalten (beschleunigt Folge-Builds deutlich).
# Für erzwungenen Clean-Build: CLEAN_NUXT_CACHE=1 ./deploy-production.sh
if [ "${CLEAN_NUXT_CACHE:-0}" = "1" ]; then
if [ -d ".nuxt" ]; then
echo " CLEAN_NUXT_CACHE=1 gesetzt: entferne .nuxt cache..."
rm -rf .nuxt
echo " ✓ .nuxt gelöscht"
fi
else
echo " Behalte .nuxt cache für schnelleren Build (CLEAN_NUXT_CACHE=1 für Clean-Build)"
fi
# Prüfe, ob node_modules vorhanden ist (für npm run build)
if [ ! -d "node_modules" ]; then
echo ""
echo "WARNING: node_modules fehlt. Installiere Dependencies..."
npm install
install_dependencies
fi
# 5. Build
@@ -188,28 +322,36 @@ echo " (This may take a few minutes...)"
echo " Checking dependencies..."
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
install_dependencies
fi
# Build mit expliziter Fehlerbehandlung und Output-Capture
BUILD_OUTPUT=$(npm run build 2>&1)
BUILD_EXIT_CODE=$?
# Zeige Build-Output
echo "$BUILD_OUTPUT"
# Build mit expliziter Fehlerbehandlung und gleichzeitiger Log-Datei
BUILD_LOG_FILE=".deploy-cache/build-$(date +%Y%m%d-%H%M%S).log"
mkdir -p ".deploy-cache"
if npm run build 2>&1 | tee "$BUILD_LOG_FILE"; then
BUILD_EXIT_CODE=0
else
BUILD_EXIT_CODE=$?
fi
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
echo ""
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
echo "Build-Log: $BUILD_LOG_FILE"
exit 1
fi
echo ""
echo " Synchronizing public documents into build output..."
sync_public_documents_to_build
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then
if rg -i "error|failed|missing" "$BUILD_LOG_FILE" >/dev/null 2>&1; then
echo ""
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
echo "Bitte prüfen Sie die Ausgabe oben."
echo "Build-Log: $BUILD_LOG_FILE"
fi
# Prüfe, ob der Build erfolgreich war - mehrere Checks
@@ -443,4 +585,3 @@ echo " pm2 status # View status"
echo " pm2 restart harheimertc # Restart instance on port 3100"
echo " pm2 restart harheimertc-3102 # Restart instance on port 3102"
echo " pm2 restart all # Restart all instances"

View File

@@ -13,6 +13,14 @@ echo ""
echo "Working directory: $(pwd)"
echo ""
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
echo "ERROR: Dieses Script darf nicht mit sudo/root ausgeführt werden."
echo "Grund: HOME=/root würde DATA/BACKUP unter /root anlegen und Git-SSH des normalen Users umgehen."
echo "Bitte so starten:"
echo " ./deploy-test.sh"
exit 1
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "ERROR: Dieses Script muss im Git-Repository ausgeführt werden (kein .git gefunden)."
exit 1
@@ -37,6 +45,12 @@ ensure_symlink_dir() {
fi
if [ -d "$src" ]; then
if [ ! -w "$src" ]; then
echo "ERROR: Keine Schreibrechte auf $src"
echo "Bitte Besitz korrigieren, dann erneut starten:"
echo " sudo chown -R $(id -un):$(id -gn) \"$src\""
exit 1
fi
echo " Moving $src -> $target (first-time migration)"
# Merge existing content into target
cp -a "$src/." "$target/" || true
@@ -53,6 +67,108 @@ has_tracked_files_under() {
git ls-files "$prefix" | head -n 1 | grep -q .
}
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
fi
}
install_dependencies_if_needed() {
local cache_dir=".deploy-cache"
local lock_hash_file="$cache_dir/package-lock.sha256"
local current_lock_hash=""
local previous_lock_hash=""
if [ ! -f "package-lock.json" ]; then
echo " package-lock.json fehlt, führe npm install aus..."
install_dependencies
return 0
fi
mkdir -p "$cache_dir"
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
if [ -f "$lock_hash_file" ]; then
previous_lock_hash="$(cat "$lock_hash_file" 2>/dev/null || true)"
fi
if [ ! -d "node_modules" ]; then
echo " node_modules fehlt, installiere Dependencies..."
install_dependencies
elif [ "$current_lock_hash" != "$previous_lock_hash" ]; then
echo " package-lock.json geändert, führe npm ci aus..."
install_dependencies
else
echo " package-lock.json unverändert, überspringe npm ci"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh"
if [ -f ".nvmrc" ]; then
echo " Using Node version from .nvmrc..."
nvm use
fi
fi
}
ensure_node_version() {
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js ist nicht im PATH."
exit 1
fi
local node_version
node_version="$(node -p 'process.versions.node')"
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
echo " Node.js $node_version"
}
sync_public_documents_to_build() {
if [ ! -d "public/documents" ]; then
echo " No public/documents directory to sync"
return 0
fi
if [ ! -d ".output/public" ]; then
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
exit 1
fi
mkdir -p ".output/public/documents"
cp -a "public/documents/." ".output/public/documents/"
echo " ✓ public/documents -> .output/public/documents synchronisiert"
local template_pdf="beitrittserklärung_template.pdf"
if [ -f "public/documents/$template_pdf" ]; then
local source_size output_size
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
exit 1
fi
echo "$template_pdf im Build verifiziert ($output_size bytes)"
fi
}
echo "0. Ensuring persistent data directories (recommended)..."
# IMPORTANT: Only symlink server/data if it's not tracked by git.
if has_tracked_files_under "server/data"; then
@@ -92,12 +208,17 @@ else
exit 1
fi
if ls public/data/*.csv >/dev/null 2>&1; then
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
elif ls public/data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
else
echo " No public CSVs to backup (public/data/*.csv not found)"
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
fi
# 2. Handle local changes and Git Pull
@@ -124,7 +245,20 @@ git clean -fd \
# Pull latest changes
echo " Pulling latest changes..."
git pull
git fetch origin dev
git checkout -B dev origin/dev
if ! git reset --hard origin/dev; then
echo "ERROR: git pull fehlgeschlagen."
echo ""
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
echo "Prüfen:"
echo " ssh -T git@tsschulz.de"
echo ""
echo "Optional auf HTTPS wechseln:"
echo " git remote set-url origin https://tsschulz.de/<owner>/<repo>.git"
echo "Oder SSH-Key für User $(id -un) hinterlegen."
exit 1
fi
# Reset any accidental changes from stash restore (should be none now)
git reset --hard HEAD >/dev/null 2>&1
@@ -140,7 +274,9 @@ fi
# 3. Install dependencies
echo ""
echo "3. Installing dependencies..."
npm install
use_project_node
ensure_node_version
install_dependencies_if_needed
# 4. Remove old build (but keep data!)
echo ""
@@ -163,18 +299,23 @@ if [ -d ".output" ]; then
echo " ✓ .output gelöscht"
fi
# Auch .nuxt Cache löschen für sauberen Build
if [ -d ".nuxt" ]; then
echo " Removing .nuxt cache..."
rm -rf .nuxt
echo " ✓ .nuxt gelöscht"
# .nuxt standardmäßig behalten (beschleunigt Folge-Builds deutlich).
# Für erzwungenen Clean-Build: CLEAN_NUXT_CACHE=1 ./deploy-test.sh
if [ "${CLEAN_NUXT_CACHE:-0}" = "1" ]; then
if [ -d ".nuxt" ]; then
echo " CLEAN_NUXT_CACHE=1 gesetzt: entferne .nuxt cache..."
rm -rf .nuxt
echo " ✓ .nuxt gelöscht"
fi
else
echo " Behalte .nuxt cache für schnelleren Build (CLEAN_NUXT_CACHE=1 für Clean-Build)"
fi
# Prüfe, ob node_modules vorhanden ist (für npm run build)
if [ ! -d "node_modules" ]; then
echo ""
echo "WARNING: node_modules fehlt. Installiere Dependencies..."
npm install
install_dependencies
fi
# 5. Build
@@ -187,28 +328,36 @@ echo " (This may take a few minutes...)"
echo " Checking dependencies..."
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
install_dependencies
fi
# Build mit expliziter Fehlerbehandlung und Output-Capture
BUILD_OUTPUT=$(npm run build 2>&1)
BUILD_EXIT_CODE=$?
# Zeige Build-Output
echo "$BUILD_OUTPUT"
# Build mit expliziter Fehlerbehandlung und gleichzeitiger Log-Datei
BUILD_LOG_FILE=".deploy-cache/build-$(date +%Y%m%d-%H%M%S).log"
mkdir -p ".deploy-cache"
if npm run build 2>&1 | tee "$BUILD_LOG_FILE"; then
BUILD_EXIT_CODE=0
else
BUILD_EXIT_CODE=$?
fi
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
echo ""
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
echo "Build-Log: $BUILD_LOG_FILE"
exit 1
fi
echo ""
echo " Synchronizing public documents into build output..."
sync_public_documents_to_build
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then
if rg -i "error|failed|missing" "$BUILD_LOG_FILE" >/dev/null 2>&1; then
echo ""
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
echo "Bitte prüfen Sie die Ausgabe oben."
echo "Build-Log: $BUILD_LOG_FILE"
fi
# Prüfe, ob der Build erfolgreich war - mehrere Checks
@@ -310,38 +459,33 @@ echo " Restored server/data from backup ($BACKUP_DIR/server-data)."
# Stelle alle CSVs wieder her
if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then
mkdir -p public/data
# WICHTIG: Überschreibe auch Dateien, die aus dem Git-Repository kommen
# Verwende cp mit -f (force) um sicherzustellen, dass Backup-Dateien Vorrang haben
# Restore into internal storage (server/data/public-data)
mkdir -p server/data/public-data
for csv_file in "$BACKUP_DIR/public-data"/*.csv; do
filename=$(basename "$csv_file")
# Überschreibe explizit, auch wenn Datei bereits existiert
cp -f "$csv_file" "public/data/$filename"
# Stelle sicher, dass die Datei wirklich überschrieben wurde
if [ -f "public/data/$filename" ]; then
# Prüfe, ob die Datei wirklich vom Backup kommt (Größenvergleich)
cp -f "$csv_file" "server/data/public-data/$filename"
if [ -f "server/data/public-data/$filename" ]; then
backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0")
restored_size=$(stat -f%z "public/data/$filename" 2>/dev/null || stat -c%s "public/data/$filename" 2>/dev/null || echo "0")
restored_size=$(stat -f%z "server/data/public-data/$filename" 2>/dev/null || stat -c%s "server/data/public-data/$filename" 2>/dev/null || echo "0")
if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then
echo " Restored public/data/$filename from backup ($backup_size bytes)"
echo " \u2713 Restored server/data/public-data/$filename from backup ($backup_size bytes)"
else
echo " WARNING: public/data/$filename Größe stimmt nicht überein (Backup: $backup_size, Restored: $restored_size)"
echo " \u26a0 WARNING: server/data/public-data/$filename size mismatch (Backup: $backup_size, Restored: $restored_size)"
fi
else
echo " ERROR: Konnte public/data/$filename nicht wiederherstellen!"
echo " \u274c ERROR: Konnte server/data/public-data/$filename nicht wiederherstellen!"
fi
done
echo " All public/data/*.csv files restored from backup ($BACKUP_DIR/public-data)."
# Zusätzliche Sicherheit: Entferne public/data Dateien aus Git-Index, falls sie getrackt sind
# (nach dem Restore, damit sie nicht beim nächsten git reset überschrieben werden)
if git ls-files --error-unmatch public/data/*.csv >/dev/null 2>&1; then
echo " WARNING: public/data/*.csv Dateien sind noch im Git getrackt!"
echo " Entferne sie aus dem Git-Index (Dateien bleiben erhalten)..."
git rm --cached public/data/*.csv 2>/dev/null || true
echo " ✓ public/data/*.csv aus Git-Index entfernt"
echo " \u2713 All public-data files restored into server/data/public-data ($BACKUP_DIR/public-data)."
# Optional: synchronize internal public-data into public/data for legacy builds
# This uses the project's sync script and forces overwrite in public/data.
if command -v node >/dev/null 2>&1 && [ -f scripts/sync-public-data.js ]; then
echo " Synchronizing server/data/public-data -> public/data (using scripts/sync-public-data.js --force)"
node scripts/sync-public-data.js --force || echo " WARNING: sync script failed"
else
echo " Note: To publish CSVs to public/data run: node scripts/sync-public-data.js --force"
fi
else
echo " No public CSVs to restore"

View File

@@ -7,7 +7,7 @@ export default [
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.vue', '**/*.js'],
files: ['**/*.vue', '**/*.js', '**/*.mjs', '**/*.cjs'],
languageOptions: {
parser: parser,
ecmaVersion: 'latest',
@@ -81,6 +81,8 @@ export default [
'node_modules/**',
'.output/**',
'.nuxt/**',
'**/.output/**',
'**/.nuxt/**',
'.next/**',
'dist/**',
'build/**',
@@ -88,6 +90,7 @@ export default [
'*.config.ts',
'*.config.cjs',
'*.cjs',
'**/*.cjs',
'temp/**',
'backups/**',
'public/**',

View File

@@ -10,7 +10,8 @@ try {
// Helper function to create env object
function createEnv(port) {
return {
NODE_ENV: 'production',
NODE_ENV: process.env.NODE_ENV || 'development',
APP_ENV: process.env.APP_ENV || 'test',
PORT: port,
// Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,

Some files were not shown because too many files have changed in this diff Show More