feat: add homepage components and API for settings and spielplan options

- Introduced new Vue components for homepage teasers: HomeLinksTeaser, HomeSpielplanTeamWidget, HomeTrainingTeaser, and HomeVereinsmeisterschaftenTeaser.
- Created XML layout for tablet app window dump.
- Implemented API endpoints for fetching and updating homepage settings.
- Added API for retrieving spielplan options, including team extraction logic.
This commit is contained in:
Torsten Schulz (local)
2026-05-29 15:37:45 +02:00
parent 1ea9596006
commit b8bdbf0a8d
39 changed files with 3867 additions and 163 deletions

View File

@@ -280,6 +280,7 @@ Detaillierte Aufgaben (priorisiert):
- B5: Config / Seiten (Inhalte)
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
- B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter
- B6: Diagnostics / Passwort-Reset-Diagnose
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
- C1: Offline-/Caching-Strategie
@@ -328,33 +329,36 @@ Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Trac
- [x] B4.1: Entwurf → Senden Flow mit Preview
- [x] B4.2: Gruppenverwaltung (CRUD)
- [ ] B5: Config / Seiten
- [x] B5: Config / Seiten
- WebStatus: Die WebUI bietet bereits umfassende CMSUIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSVImport/Export, TabbedUIs, ImageUpload, nativelike Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSVExport/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher ConfigEditor.
- AndroidStatus: In der AndroidApp sind diese Bereiche derzeit nur rudimentär bzw. als Platzhalter umgesetzt (Startseite, Vereinsmeisterschaften, Sportbetrieb, Einstellungen, PasswortResetDiagnose fehlen noch als vollwertige AdminTools).
- Konkrete AndroidToDos (B5.x):
- B5.1: `cms/startseite` (StartseitenLayout)
- Implementieren: Reorderable list + Visibility Toggle, Save → `PUT /api/config` (`homepage.sections`), Lade/SaveSnackbar, Undo/Historie.
- B5.2: `cms/vereinsmeisterschaften`
- Implementieren: CSVLoad/Parser, UI zur Anzeige gruppiert nach Jahr/Kategorie, Modal für ErgebnisCRUD, CSV Export via `/api/cms/save-csv`.
- B5.3: `cms/sportbetrieb`
- Implementieren: Tabbed UI (Termine / Mannschaften / Spielpläne), Wiederverwendung von bestehenden native Komponenten (`TermineScreen`, `MannschaftenScreen`, `SpielplanScreen`) und AdminModi (Add/Edit/Delete).
- B5.4: `cms/einstellungen`
- Implementieren: Tabbed Config Editor (Vereinsdaten, Training, Trainer, Mitgliedschaft), ImageUpload, PDFFeld für Satzung, Validierung + Save/Preview.
- B5.5: Roundtrip & Tests
- RoundtripTests: RichText ↔ Web (Quill/HTML), CSV parser/tests für Vereinsmeisterschaften, ViewModelUnitTests und ComposeUIsmoke tests für Save/Load flows.
- AndroidStatus: Implementiert — die AndroidApp enthält native CMSScreens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/LoadFlows via `CmsViewModel`.
- Umsetzung (B5.x):
- [x] B5.1: `cms/startseite` (StartseitenLayout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
- [x] B5.2: `cms/vereinsmeisterschaften` — CSVParser/CSVSave integration and modal CRUD (native UI present).
- [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components.
- [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save.
- [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening.
- [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`).
- [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
- [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
- WebStatus: `cms/passwort-reset-diagnose` zeigt vollständige DiagnoseUI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren ResetVersuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
- AndroidStatus: rudimentär/fehlend — AdminDiagnose ist nicht vollständig portiert.
- AndroidStatus: umgesetzt — native DiagnoseUI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und ShareExport der maskierten Logs.
- Konkrete AndroidToDos (B6.x):
- B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, StatusBadges und Details.
- B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: EMail Maskierung beibehalten.
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, StatusBadges und Details.
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: EMail Maskierung beibehalten.
- [ ] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
- [ ] C2: Tests & CI
- [ ] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
- [ ] C2.2: Compose-UI-Tests für kritische Flows
- [ ] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
- Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h).
- Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen.
- Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben.
- Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden.
- [x] C2: Tests & CI
- [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
- Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert.
- [x] C2.2: Compose-UI-Tests für kritische Flows
- Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt.
- [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
- Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün.
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.

View File

@@ -135,6 +135,7 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
// Hilt testing
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated

View File

@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name="dagger.hilt.android.testing.HiltTestApplication"
android:allowBackup="false">
<application android:allowBackup="false">
</application>
</manifest>

View File

@@ -41,6 +41,7 @@ object TestHiltModules {
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
}
}

View File

@@ -0,0 +1,390 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.navigation.compose.rememberNavController
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.ContactResponse
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.MembershipRequest
import de.harheimertc.data.MembershipResponse
import de.harheimertc.data.NewsletterCreateRequest
import de.harheimertc.data.NewsletterCreateResponse
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.NewsletterSendResponse
import de.harheimertc.data.NewsletterSubscriptionRequest
import de.harheimertc.data.NewsPublicResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
import de.harheimertc.data.PasskeysResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.ProfileResponse
import de.harheimertc.data.ProfileUpdateRequest
import de.harheimertc.data.PublicGalleryImageDto
import de.harheimertc.data.GalleryListResponse
import de.harheimertc.data.GalleryUploadResponse
import de.harheimertc.data.RefreshRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RemovePasskeyRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import de.harheimertc.data.TermineResponse
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
@RunWith(AndroidJUnit4::class)
class CmsExistingScreensSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsDashboard_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
}
@Test
fun cmsStartseite_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsInhalte_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsVereinsmeisterschaften_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.saveCsvCalls)
}
@Test
fun cmsSportbetrieb_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsEinstellungen_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsMitgliederverwaltung_clickFreischalten() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Freischalten").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserActiveCalls)
}
@Test
fun cmsBenutzer_clickRollenSpeichern() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Rollen").performClick()
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserRolesCalls)
}
@Test
fun cmsContactRequests_replySenden() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Antworten").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
composeTestRule.onNodeWithText("Senden").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.replyContactCalls)
}
@Test
fun cmsNewsletter_createAndSave() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
}
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.createNewsletterCalls)
}
@Test
fun cmsPasswordResetDiagnostics_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
}
private fun createViewModel(api: RecordingApiService): CmsViewModel {
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repository = CmsRepository(api, cache)
return CmsViewModel(repository)
}
private fun renderWithState(viewModel: CmsViewModel) {
val readyState = CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
),
contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
),
newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
),
newsletterGroups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
),
passwordResetAttempts = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
),
news = emptyList(),
meisterschaften = listOf(
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
),
)
val field = CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
}
}
private class RecordingApiService : ApiService {
var updateConfigCalls = 0
var saveCsvCalls = 0
var updateUserActiveCalls = 0
var updateUserRolesCalls = 0
var replyContactCalls = 0
var createNewsletterCalls = 0
private val config = ConfigResponse()
private val users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
)
private val contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
)
private val newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
)
private val groups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
)
private val diagnostics = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
)
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
updateConfigCalls++
return Response.success(request)
}
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> = Response.success(ResponseBody.create(null, "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\""))
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
saveCsvCalls++
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
}
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
updateUserRolesCalls++
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
}
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
updateUserActiveCalls++
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
}
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
replyContactCalls++
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
}
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
createNewsletterCalls++
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
}
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
}

View File

@@ -0,0 +1,122 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.rememberNavController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.repositories.CmsRepository
import okhttp3.ResponseBody
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
@RunWith(AndroidJUnit4::class)
class CmsPasswordResetDiagnosticsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
val api = createDiagnosticsApiService()
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repo = CmsRepository(api, cache)
val viewModel = CmsViewModel(repo)
composeTestRule.setContent {
val navController = rememberNavController()
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.waitUntil(15_000) {
try {
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
true
} catch (_: Throwable) {
false
}
}
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
// Trigger a manual refresh to validate the main interaction path.
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
composeTestRule.waitForIdle()
}
private fun createDiagnosticsApiService(): ApiService {
val attempt = PasswordResetAttemptDto(
requestId = "req-1",
startedAt = "2026-05-29T10:15:00Z",
emailMasked = "m***@example.com",
ip = "127.0.0.1",
failed = true,
steps = listOf(
PasswordResetStepDto(
ts = "2026-05-29T10:15:01Z",
step = "mail_configuration",
status = "failed",
reason = "smtp_credentials_missing",
),
),
)
val handler = InvocationHandler { _, method: Method, _ ->
when (method.name) {
"config" -> Response.success(ConfigResponse())
"users" -> Response.success(CmsUsersResponse())
"cmsUsers" -> Response.success(CmsUsersResponse())
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
"passwordResetDiagnostics" -> Response.success(
PasswordResetDiagnosticsResponse(
retentionHours = 72,
searchedEmail = "",
matchingUsers = listOf(
de.harheimertc.data.PasswordResetMatchingUserDto(
id = "u1",
name = "Max Muster",
email = "max@example.com",
active = true,
),
),
attempts = listOf(attempt),
),
)
"vereinsmeisterschaften" -> Response.success(ResponseBody.create(null, "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n"))
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
}
}
return Proxy.newProxyInstance(
ApiService::class.java.classLoader,
arrayOf(ApiService::class.java),
handler,
) as ApiService
}
}

View File

@@ -0,0 +1,156 @@
package de.harheimertc.ui.screens.cms
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.core.app.ApplicationProvider
import com.squareup.moshi.Moshi
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith
import retrofit2.Response
import okhttp3.MultipartBody
import okhttp3.RequestBody
import de.harheimertc.data.*
import kotlinx.coroutines.flow.MutableStateFlow
import de.harheimertc.repositories.CmsRepository
import androidx.navigation.compose.rememberNavController
@RunWith(AndroidJUnit4::class)
class CmsStartseiteSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsStartseite_rendersWithDefaultState() {
// prepare a minimal fake ApiService that returns empty/neutral responses
val fakeApi = object : ApiService {
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
}
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val moshi = Moshi.Builder().build()
val cache = SecureOfflineCache(context, moshi)
val repo = CmsRepository(fakeApi, cache)
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
// set a ready state to avoid waiting for async network loads in Vm.init
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = emptyList(),
contactRequests = emptyList(),
newsletters = emptyList(),
newsletterGroups = emptyList(),
passwordResetAttempts = emptyList(),
news = emptyList(),
)
try {
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
val current = field.get(vm) as? MutableStateFlow<*>
if (current is MutableStateFlow<*>) {
@Suppress("UNCHECKED_CAST")
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
}
} catch (_: Throwable) { /* best-effort, continue */ }
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
}
// dump semantics tree for debugging
try {
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
} catch (_: Throwable) { }
// wait for the main title and info rows to appear
fun waitForText(text: String, timeoutMs: Long = 20000L) {
composeTestRule.waitUntil(timeoutMs) {
try {
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
} catch (_: AssertionError) { false }
}
}
waitForText("Startseite")
waitForText("Öffentliche")
// basic assertions (use substring matching)
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
}
}

View File

@@ -0,0 +1,144 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsUiAutomatorClickTest {
@Test
fun clickThroughExistingCmsPages_andTrySave() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val device = UiDevice.getInstance(instrumentation)
val packageName = "de.harheimertc.local"
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
context.startActivity(launchIntent)
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
clickText(device, "Intern")
clickText(device, "CMS")
openCmsCard(device, "Startseite")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Inhalte")
clickIfPresent(device, "Inhalte speichern")
backToCmsDashboard(device)
openCmsCard(device, "Vereinsmeisterschaften")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Sportbetrieb")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Einstellungen")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Mitgliederverwaltung")
clickIfPresent(device, "Freischalten")
backToCmsDashboard(device)
openCmsCard(device, "Kontaktanfragen")
clickIfPresent(device, "Antworten")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
openCmsCard(device, "Newsletter")
clickIfPresent(device, "Newsletter erstellen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
if (openCmsCardIfAvailable(device, "Benutzer")) {
clickIfPresent(device, "Rollen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
}
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
}
private fun openCmsCard(device: UiDevice, label: String) {
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
clickText(device, label)
}
}
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
if (clickIfPresent(device, label, 1500)) return true
return clickTextWithScroll(device, label)
}
private fun backToCmsDashboard(device: UiDevice) {
if (!clickIfPresent(device, "CMS", 3000)) {
device.pressBack()
device.waitForIdle()
}
}
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
requireNotNull(obj) { "Text nicht gefunden: $text" }
obj.click()
device.waitForIdle()
return obj
}
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
if (obj != null) {
obj.click()
device.waitForIdle()
return true
}
return false
}
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
if (clickIfPresent(device, text, 1500)) return true
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
return false
}
}

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.HiltAndroidApp
import io.sentry.Sentry
import okhttp3.OkHttpClient
import javax.inject.Inject
import android.util.Log
@HiltAndroidApp
class HarheimerApplication : Application(), ImageLoaderFactory {
@@ -16,6 +17,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
lateinit var okHttpClient: OkHttpClient
override fun onCreate() {
Log.d("HILT", "HarheimerApplication.onCreate called")
super.onCreate()
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options ->

View File

@@ -6,9 +6,14 @@ 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.navigation.Destinations
import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
import dagger.hilt.android.AndroidEntryPoint
import android.util.Log
import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -24,7 +29,11 @@ class MainActivity : ComponentActivity() {
fun App() {
HarheimerTheme {
val navController = rememberNavController()
NavGraph(navController = navController)
val ctx = LocalContext.current
val activity = ctx as? ComponentActivity
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
val navigationViewModel: NavigationViewModel = hiltViewModel()
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
}
}

View File

@@ -191,6 +191,16 @@ data class AuthStatusResponse(
)
data class ResetPasswordRequest(val email: String)
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
data class SaveCsvRequest(
val filename: String,
val content: String,
)
data class SaveCsvResponse(
val success: Boolean = false,
val message: String? = null,
val writtenTo: List<String> = emptyList(),
val jsonWrittenTo: List<String> = emptyList(),
)
data class PasskeyAuthenticationOptionsRequest(
val email: String? = null,
val client: String = "android",
@@ -344,14 +354,38 @@ data class SatzungDto(
val pdfUrl: String = "",
val content: String = "",
)
data class MembershipTierDto(
val id: String = "",
val typ: String = "",
val beschreibung: String? = null,
val preis: Int = 0,
val features: List<String> = emptyList(),
)
data class LinkItemDto(
val label: String = "",
val href: String = "",
val description: String = "",
val id: String = "",
)
data class LinkSectionDto(
val title: String = "",
val items: List<LinkItemDto> = emptyList(),
val id: String = "",
)
data class HomepageSectionDto(
val id: String = "",
val enabled: Boolean = true,
val key: String? = null,
val marker: String? = null,
val config: HomepageSectionConfigDto? = null,
)
data class HomepageSectionConfigDto(
val season: String? = null,
val teamName: String? = null,
val teamAgeGroup: String? = null,
)
data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
@@ -364,10 +398,12 @@ data class SeitenDto(
data class ConfigResponse(
val training: TrainingDto = TrainingDto(),
val trainer: List<TrainerDto> = emptyList(),
val mitgliedschaft: List<MembershipTierDto> = emptyList(),
val verein: VereinDto = VereinDto(),
val vorstand: VorstandDto = VorstandDto(),
val website: WebsiteDto = WebsiteDto(),
val seiten: SeitenDto = SeitenDto(),
val homepage: HomepageDto = HomepageDto(),
)
data class CmsUserDto(
val id: String = "",
@@ -454,8 +490,17 @@ data class PasswordResetAttemptDto(
val failed: Boolean = false,
val steps: List<PasswordResetStepDto> = emptyList(),
)
data class PasswordResetMatchingUserDto(
val id: String = "",
val name: String = "",
val email: String = "",
val active: Boolean = true,
val lastLogin: String? = null,
)
data class PasswordResetDiagnosticsResponse(
val retentionHours: Int = 0,
val searchedEmail: String? = null,
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
val attempts: List<PasswordResetAttemptDto> = emptyList(),
)
@@ -520,6 +565,9 @@ interface ApiService {
@GET("/api/vereinsmeisterschaften")
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
@POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@@ -669,5 +717,8 @@ interface ApiService {
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
@GET("/api/cms/password-reset-diagnostics")
suspend fun passwordResetDiagnostics(): Response<PasswordResetDiagnosticsResponse>
suspend fun passwordResetDiagnostics(
@Query("email") email: String? = null,
@Query("failedOnly") failedOnly: Boolean = true,
): Response<PasswordResetDiagnosticsResponse>
}

View File

@@ -14,6 +14,19 @@ class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private companion object {
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config"
const val KEY_CMS_USERS = "cms_users"
const val KEY_CONTACT_REQUESTS = "contact_requests"
const val KEY_NEWSLETTERS = "newsletters"
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
const val TIMESTAMP_SUFFIX = "_ts"
}
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -27,51 +40,99 @@ class SecureOfflineCache @Inject constructor(
)
}
fun putBirthdays(response: BirthdaysResponse) = put("birthdays", response, BirthdaysResponse::class.java)
fun getBirthdays(): BirthdaysResponse? = get("birthdays", BirthdaysResponse::class.java)
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
fun putMembers(response: MembersResponse) = put("members", response, MembersResponse::class.java)
fun getMembers(): MembersResponse? = get("members", MembersResponse::class.java)
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
fun putNews(response: NewsResponse) = put("member_news", response, NewsResponse::class.java)
fun getNews(): NewsResponse? = get("member_news", NewsResponse::class.java)
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
fun putConfig(response: ConfigResponse) = put("cms_config", response, ConfigResponse::class.java)
fun getConfig(): ConfigResponse? = get("cms_config", ConfigResponse::class.java)
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
fun putCmsUsers(response: CmsUsersResponse) = put("cms_users", response, CmsUsersResponse::class.java)
fun getCmsUsers(): CmsUsersResponse? = get("cms_users", CmsUsersResponse::class.java)
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
fun putContactRequests(response: List<ContactRequestDto>) {
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
preferences.edit().putString("contact_requests", json).apply()
preferences.edit()
.putString(KEY_CONTACT_REQUESTS, json)
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
.apply()
}
fun getContactRequests(): List<ContactRequestDto>? {
val json = preferences.getString("contact_requests", null) ?: return null
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
}
fun putNewsletters(response: NewsletterListResponse) = put("newsletters", response, NewsletterListResponse::class.java)
fun getNewsletters(): NewsletterListResponse? = get("newsletters", NewsletterListResponse::class.java)
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put("newsletter_groups", response, NewsletterGroupsResponse::class.java)
fun getNewsletterGroups(): NewsletterGroupsResponse? = get("newsletter_groups", NewsletterGroupsResponse::class.java)
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
put("password_reset_diagnostics", response, PasswordResetDiagnosticsResponse::class.java)
fun getPasswordResetDiagnostics(): PasswordResetDiagnosticsResponse? =
get("password_reset_diagnostics", PasswordResetDiagnosticsResponse::class.java)
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
fun clearCmsProtectedCaches() {
clear(
KEY_CMS_CONFIG,
KEY_CMS_USERS,
KEY_CONTACT_REQUESTS,
KEY_NEWSLETTERS,
KEY_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS,
)
}
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
private fun <T> put(key: String, value: T, type: Class<T>) {
val json = moshi.adapter(type).toJson(value)
preferences.edit().putString(key, json).apply()
preferences.edit()
.putString(key, json)
.putLong(timestampKey(key), System.currentTimeMillis())
.apply()
}
private fun <T> get(key: String, type: Class<T>): T? {
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
if (isExpired(key, maxAgeMillis)) return null
val json = preferences.getString(key, null) ?: return null
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
}
private fun clear(vararg keys: String) {
val editor = preferences.edit()
keys.forEach { key ->
editor.remove(key)
editor.remove(timestampKey(key))
}
editor.apply()
}
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
if (maxAgeMillis == null) return false
val savedAt = preferences.getLong(timestampKey(key), 0L)
if (savedAt <= 0L) return true
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
}
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
}

View File

@@ -7,6 +7,8 @@ import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject
@@ -14,6 +16,11 @@ class CmsRepository @Inject constructor(
private val api: ApiService,
private val cache: SecureOfflineCache,
) {
private companion object {
const val CMS_CACHE_MAX_AGE_MS = 24L * 60L * 60L * 1000L
const val PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS = 6L * 60L * 60L * 1000L
}
suspend fun config(): Result<ConfigResponse> =
fetchEncryptedFallback(
load = {
@@ -22,14 +29,58 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putConfig,
cached = cache::getConfig,
cached = { cache.getConfig(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
)
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
val response = api.updateConfig(config)
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
response.body() ?: error("Leere Antwort vom Server.")
val saved = response.body() ?: error("Leere Antwort vom Server.")
cache.putConfig(saved)
saved
}
suspend fun vereinsmeisterschaften(): 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) { "" },
)
}
}
suspend fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>): Result<SaveCsvResponse> = runCatching {
val csvHeader = "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2"
val csvRows = results.map { result ->
listOf(
result.year,
result.category,
result.rank,
result.playerOne,
result.playerTwo,
result.note,
result.imageOne,
result.imageTwo,
).joinToString(",") { value -> "\"${value.replace("\"", "\"\"")}\"" }
}
val response = api.saveCsv(
SaveCsvRequest(
filename = "vereinsmeisterschaften.csv",
content = listOf(csvHeader).plus(csvRows).joinToString("\n"),
),
)
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun users(): Result<CmsUsersResponse> =
@@ -40,7 +91,7 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putCmsUsers,
cached = cache::getCmsUsers,
cached = { cache.getCmsUsers(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Benutzer konnten nicht geladen werden.",
)
@@ -48,6 +99,7 @@ class CmsRepository @Inject constructor(
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
val response = api.updateUserRoles(req)
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -55,12 +107,14 @@ class CmsRepository @Inject constructor(
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
val response = api.updateUserActive(req)
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.resendInvite(id)
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -72,7 +126,7 @@ class CmsRepository @Inject constructor(
response.body() ?: emptyList()
},
save = cache::putContactRequests,
cached = cache::getContactRequests,
cached = { cache.getContactRequests(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
)
@@ -80,12 +134,14 @@ class CmsRepository @Inject constructor(
val req = ApiService.ContactReplyRequest(message)
val response = api.replyToContactRequest(id, req)
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
cache.clearContactRequestsCache()
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
}
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
val response = api.toggleContactRequestStatus(id)
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
cache.clearContactRequestsCache()
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
}
@@ -97,7 +153,7 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putNewsletters,
cached = cache::getNewsletters,
cached = { cache.getNewsletters(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Newsletter konnten nicht geladen werden.",
)
@@ -109,21 +165,38 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putNewsletterGroups,
cached = cache::getNewsletterGroups,
cached = { cache.getNewsletterGroups(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
)
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> =
fetchEncryptedFallback(
suspend fun passwordResetDiagnostics(
email: String? = null,
failedOnly: Boolean = true,
): Result<PasswordResetDiagnosticsResponse> {
val normalizedEmail = email?.trim().orEmpty()
val canUseSharedCache = normalizedEmail.isBlank() && failedOnly
return fetchEncryptedFallback(
load = {
val response = api.passwordResetDiagnostics()
val response = api.passwordResetDiagnostics(
email = normalizedEmail.takeIf { it.isNotBlank() },
failedOnly = failedOnly,
)
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putPasswordResetDiagnostics,
cached = cache::getPasswordResetDiagnostics,
save = { response ->
if (canUseSharedCache) cache.putPasswordResetDiagnostics(response)
},
cached = {
if (canUseSharedCache) {
cache.getPasswordResetDiagnostics(PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS)
} else {
null
}
},
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
)
}
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
fetchEncryptedFallback(
@@ -133,37 +206,42 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.NewsResponse()
},
save = cache::putNews,
cached = cache::getNews,
cached = { cache.getNews(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "News konnten nicht geladen werden.",
)
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveNews(request)
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
cache.clearCmsNewsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.createNewsletter(request)
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.updateNewsletter(id, patch)
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
val response = api.sendNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -171,24 +249,28 @@ class CmsRepository @Inject constructor(
// use generic POST via Retrofit? build request through create endpoint
val response = api.createNewsletterGroup(payload)
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.updateNewsletterGroup(id, patch)
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletterGroup(id)
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -205,3 +287,33 @@ class CmsRepository @Inject constructor(
}
}
}
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
}

View File

@@ -0,0 +1,51 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getSections(): List<HomepageSectionDto>? {
val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null
return runCatching { sectionListAdapter.fromJson(json) }.getOrNull()
}
fun setSections(sections: List<HomepageSectionDto>) {
val json = sectionListAdapter.toJson(sections)
preferences.edit().putString(HOME_SECTIONS_KEY, json).apply()
}
fun clearSections() {
preferences.edit().remove(HOME_SECTIONS_KEY).apply()
}
private companion object {
const val HOME_SECTIONS_KEY = "home_sections"
}
}

View File

@@ -1,8 +1,11 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import javax.inject.Inject
import javax.inject.Singleton
@@ -10,15 +13,37 @@ import javax.inject.Singleton
data class HomeData(
val termine: List<TerminDto>,
val spiele: List<SpielDto>,
val spielplanSeasons: List<SeasonDto>,
val selectedSpielplanSeason: String?,
val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
)
@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 spielplanResponse = api.spielplan().body()
val spiele = spielplanResponse?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
HomeData(termine, spiele, news)
val homepageSections = runCatching {
val configResponse = api.config()
if (!configResponse.isSuccessful) return@runCatching emptyList()
configResponse.body()?.homepage?.sections.orEmpty()
}.getOrDefault(emptyList())
HomeData(
termine = termine,
spiele = spiele,
spielplanSeasons = spielplanResponse?.seasons.orEmpty(),
selectedSpielplanSeason = spielplanResponse?.season,
news = news,
homepageSections = homepageSections,
)
}
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}
}

View File

@@ -344,6 +344,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))

View File

@@ -20,8 +20,9 @@ import de.harheimertc.ui.components.AppNavigationHeader
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.Home.route,
navigationViewModel: NavigationViewModel = hiltViewModel(),
navigationViewModelParam: NavigationViewModel? = null,
) {
val navigationViewModel: NavigationViewModel = navigationViewModelParam ?: hiltViewModel()
val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) {

View File

@@ -1,5 +1,6 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
@@ -30,28 +31,37 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.navigation.NavController
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.MeisterschaftResult
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
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
@@ -65,9 +75,79 @@ fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean
@Composable
fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsConfigPage(navController, showBackNavigation, "Startseite", "Öffentliche Startseiteninhalte", state.config) {
InfoRow("Öffentliche News", "${state.newsletters.count { it.sentAt != null }} versendete Newsletter")
InfoRow("Kontaktanfragen", "${state.contactRequests.size} Einträge")
val config = state.config
val sections = remember { mutableStateListOf<HomepageSectionDto>() }
LaunchedEffect(config) {
sections.clear()
sections.addAll(normalizedHomepageSections(config))
}
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Button(
onClick = {
viewModel.saveConfig(
config.copy(
homepage = config.homepage.copy(sections = sections.toList()),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(
modifier = Modifier.fillMaxWidth().padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Verfügbare Elemente", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(
"Verwenden Sie die Positions-Buttons, um die Reihenfolge zu ändern, oder blenden Sie Elemente aus.",
color = Accent500,
)
sections.forEachIndexed { index, section ->
HomepageSectionCard(
section = section,
index = index,
lastIndex = sections.lastIndex,
onMoveUp = {
if (index > 0) {
val current = sections.removeAt(index)
sections.add(index - 1, current)
}
},
onMoveDown = {
if (index < sections.lastIndex) {
val current = sections.removeAt(index)
sections.add(index + 1, current)
}
},
onEnabledChange = { enabled ->
sections[index] = section.copy(enabled = enabled)
},
)
}
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Text(
"Deaktivierte Elemente bleiben in der Konfiguration erhalten, werden aber auf der Startseite nicht angezeigt.",
color = Primary600,
modifier = Modifier.fillMaxWidth().padding(14.dp),
)
}
FormMessages(state.error, state.message)
}
}
}
}
}
}
}
@@ -138,26 +218,337 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
@Composable
fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsConfigPage(navController, showBackNavigation, "Vereinsmeisterschaften", "CSV- und Personenbild-Inhalte", state.config) {
InfoRow("Datenquelle", "/api/vereinsmeisterschaften")
InfoRow("Hinweis", "Die Ergebnisliste ist nativ im öffentlichen Bereich portiert.")
val results = remember { mutableStateListOf<MeisterschaftResult>() }
var selectedYear by remember { mutableStateOf("Alle Jahre") }
var editDialogOpen by remember { mutableStateOf(false) }
var noteDialogOpen by remember { mutableStateOf(false) }
var editingOriginal by remember { mutableStateOf<MeisterschaftResult?>(null) }
var noteYear by remember { mutableStateOf("") }
var year by remember { mutableStateOf("") }
var category by remember { mutableStateOf("") }
var rank by remember { mutableStateOf("") }
var playerOne by remember { mutableStateOf("") }
var playerTwo by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
var imageOne by remember { mutableStateOf("") }
var imageTwo by remember { mutableStateOf("") }
LaunchedEffect(state.meisterschaften) {
results.clear()
results.addAll(state.meisterschaften)
}
val years = results.map { it.year }.filter { it.isNotBlank() }.distinct().sortedDescending()
val visibleResults = if (selectedYear == "Alle Jahre") results.toList() else results.filter { it.year == selectedYear }
val groupedResults = visibleResults
.groupBy { it.year }
.toSortedMap(compareByDescending { it })
fun resetEditor() {
editingOriginal = null
year = ""
category = ""
rank = ""
playerOne = ""
playerTwo = ""
note = ""
imageOne = ""
imageTwo = ""
}
fun saveAll() {
viewModel.saveVereinsmeisterschaften(results.toList())
}
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
when {
state.loading -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Button(onClick = {
resetEditor()
editDialogOpen = true
}, modifier = Modifier.weight(1f)) {
Text("Ergebnis hinzufügen")
}
Button(onClick = { saveAll() }, enabled = !state.saving, modifier = Modifier.weight(1f)) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { selectedYear = "Alle Jahre" }) { Text("Alle Jahre") }
years.forEach { yearValue ->
OutlinedButton(onClick = { selectedYear = yearValue }) { Text(yearValue) }
}
}
}
if (groupedResults.isEmpty()) {
item { EmptyCard("Keine Vereinsmeisterschaften gefunden.") }
}
groupedResults.forEach { (yearValue, yearResults) ->
item {
val yearNote = yearResults.firstOrNull { it.category.isBlank() && it.rank.isBlank() && it.playerOne.isBlank() && it.playerTwo.isBlank() && it.note.isNotBlank() }
DataCard(yearValue) {
yearNote?.let { noteEntry ->
Surface(color = Color(0xFFFEF3C7), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(noteEntry.note, color = Color(0xFF92400E))
TextButton(onClick = {
noteYear = yearValue
note = noteEntry.note
noteDialogOpen = true
}) { Text("Bemerkung bearbeiten") }
}
}
} ?: TextButton(onClick = {
noteYear = yearValue
note = ""
noteDialogOpen = true
}) { Text("Jahresbemerkung hinzufügen") }
yearResults
.filter { it.category.isNotBlank() }
.groupBy { it.category }
.forEach { (categoryValue, categoryResults) ->
Text(categoryValue, style = MaterialTheme.typography.titleMedium, color = Accent900, modifier = Modifier.padding(top = 8.dp))
categoryResults.sortedBy { it.rank.toIntOrNull() ?: Int.MAX_VALUE }.forEach { entry ->
Surface(color = Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${entry.rank}. ${listOf(entry.playerOne, entry.playerTwo).filter { it.isNotBlank() }.joinToString(" / ")}", color = Accent900, fontWeight = FontWeight.SemiBold)
if (entry.note.isNotBlank()) Text(entry.note, color = Accent500)
if (entry.imageOne.isNotBlank() || entry.imageTwo.isNotBlank()) {
Text("Bilder: ${listOf(entry.imageOne, entry.imageTwo).filter { it.isNotBlank() }.joinToString(", ")}", color = Accent700)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
editingOriginal = entry
year = entry.year
category = entry.category
rank = entry.rank
playerOne = entry.playerOne
playerTwo = entry.playerTwo
note = entry.note
imageOne = entry.imageOne
imageTwo = entry.imageTwo
editDialogOpen = true
}) { Text("Bearbeiten") }
TextButton(onClick = {
results.remove(entry)
saveAll()
}) { Text("Löschen") }
}
}
}
}
}
}
}
}
item { FormMessages(state.error, state.message) }
}
}
}
if (editDialogOpen) {
AlertDialog(
onDismissRequest = { editDialogOpen = false },
title = { Text(if (editingOriginal == null) "Neues Ergebnis" else "Ergebnis bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = year, onValueChange = { year = it }, label = { Text("Jahr") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = category, onValueChange = { category = it }, label = { Text("Kategorie") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = rank, onValueChange = { rank = it }, label = { Text("Platz") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = playerOne, onValueChange = { playerOne = it }, label = { Text("Spieler 1") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = playerTwo, onValueChange = { playerTwo = it }, label = { Text("Spieler 2") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Bemerkung") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = imageOne, onValueChange = { imageOne = it }, label = { Text("Bilddatei Spieler 1") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = imageTwo, onValueChange = { imageTwo = it }, label = { Text("Bilddatei Spieler 2") }, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
Button(onClick = {
val updated = MeisterschaftResult(
year = year,
category = category,
rank = rank,
playerOne = playerOne,
playerTwo = playerTwo,
note = note,
imageOne = imageOne,
imageTwo = imageTwo,
)
editingOriginal?.let { original -> results.remove(original) }
results.add(updated)
editDialogOpen = false
saveAll()
}) { Text("Speichern") }
},
dismissButton = {
TextButton(onClick = { editDialogOpen = false }) { Text("Abbrechen") }
},
)
}
if (noteDialogOpen) {
AlertDialog(
onDismissRequest = { noteDialogOpen = false },
title = { Text("Jahresbemerkung") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = noteYear, onValueChange = { noteYear = it }, label = { Text("Jahr") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Bemerkung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
}
},
confirmButton = {
Button(onClick = {
results.removeAll { it.year == noteYear && it.category.isBlank() && it.rank.isBlank() && it.playerOne.isBlank() && it.playerTwo.isBlank() }
if (note.isNotBlank()) {
results.add(
MeisterschaftResult(
year = noteYear,
category = "",
rank = "",
playerOne = "",
playerTwo = "",
note = note,
imageOne = "",
imageTwo = "",
),
)
}
noteDialogOpen = false
saveAll()
}) { Text("Speichern") }
},
dismissButton = {
TextButton(onClick = { noteDialogOpen = false }) { Text("Abbrechen") }
},
)
}
}
@Composable
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsConfigPage(navController, showBackNavigation, "Sportbetrieb", "Training, Trainer und Mannschaftsinhalte", state.config) { config ->
InfoRow("Trainingszeiten", "${config.training.zeiten.size} Einträge")
InfoRow("Trainer", "${config.trainer.size} Personen")
InfoRow("Spielsysteme", "/data/spielsysteme.csv")
val config = state.config
var ortName by remember { mutableStateOf("") }
var ortStrasse by remember { mutableStateOf("") }
var ortPlz by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
LaunchedEffect(config) {
config?.let {
ortName = it.training.ort.name
ortStrasse = it.training.ort.strasse
ortPlz = it.training.ort.plz
ortOrt = it.training.ort.ort
trainingTimes.clear()
trainingTimes.addAll(it.training.zeiten)
trainers.clear()
trainers.addAll(it.trainer)
}
}
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Button(
onClick = {
viewModel.saveConfig(
config.copy(
training = config.training.copy(
ort = config.training.ort.copy(
name = ortName,
strasse = ortStrasse,
plz = ortPlz,
ort = ortOrt,
),
zeiten = trainingTimes.toList(),
),
trainer = trainers.toList(),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
DataCard("Trainingsort") {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
item {
DataCard("Trainingszeiten") {
trainingTimes.forEachIndexed { index, zeit ->
TrainingTimeEditorCard(
zeit = zeit,
onChange = { updated -> trainingTimes[index] = updated },
onRemove = { trainingTimes.removeAt(index) },
)
}
Button(
onClick = {
trainingTimes.add(
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainingszeit hinzufügen")
}
}
}
item {
DataCard("Trainer") {
trainers.forEachIndexed { index, trainer ->
TrainerEditorCard(
trainer = trainer,
onChange = { updated -> trainers[index] = updated },
onRemove = { trainers.removeAt(index) },
)
}
Button(
onClick = {
trainers.add(
de.harheimertc.data.TrainerDto(
id = "trainer-${System.currentTimeMillis()}",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainer hinzufügen")
}
}
}
item { FormMessages(state.error, state.message) }
}
}
}
}
@Composable
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users)
CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users, viewModel)
}
@Composable
@@ -166,16 +557,23 @@ fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: B
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index]) }
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
}
}
@Composable
fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
fun CmsNewsletterScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: CmsViewModel = hiltViewModel(),
canWriteOverride: Boolean? = null,
) {
val state by viewModel.state.collectAsState()
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
val canWrite = canWriteOverride ?: run {
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
loginState.roles.any { it == "admin" || it == "vorstand" }
}
// dialog state for newsletters
var newsletterDialogOpen by remember { mutableStateOf(false) }
var editingNewsletter by remember { mutableStateOf<NewsletterDto?>(null) }
@@ -346,26 +744,204 @@ fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolea
@Composable
fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsConfigPage(navController, showBackNavigation, "Einstellungen", "Konfiguration und Systemstatus", state.config) { config ->
InfoRow("Vorstand", listOf(config.vorstand.vorsitzender, config.vorstand.stellvertreter, config.vorstand.kassenwart).count { it.email.isNotBlank() }.toString())
InfoRow("Trainer", config.trainer.size.toString())
InfoRow("Trainingsort", config.training.ort.name.ifBlank { "Nicht gesetzt" })
val config = state.config
var vereinName by remember { mutableStateOf("") }
var useVorsitzenderAddress by remember { mutableStateOf(false) }
var vereinStrasse by remember { mutableStateOf("") }
var vereinPlz by remember { mutableStateOf("") }
var vereinOrt by remember { mutableStateOf("") }
var websiteVorname by remember { mutableStateOf("") }
var websiteNachname by remember { mutableStateOf("") }
var websiteEmail by remember { mutableStateOf("") }
LaunchedEffect(config) {
config?.let {
vereinName = it.verein.name
useVorsitzenderAddress = it.verein.useVorsitzenderAddress
vereinStrasse = it.verein.strasse
vereinPlz = it.verein.plz
vereinOrt = it.verein.ort
websiteVorname = it.website.verantwortlicher.vorname
websiteNachname = it.website.verantwortlicher.nachname
websiteEmail = it.website.verantwortlicher.email
}
}
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Button(
onClick = {
viewModel.saveConfig(
config.copy(
verein = config.verein.copy(
name = vereinName,
useVorsitzenderAddress = useVorsitzenderAddress,
strasse = vereinStrasse,
plz = vereinPlz,
ort = vereinOrt,
),
website = config.website.copy(
verantwortlicher = config.website.verantwortlicher.copy(
vorname = websiteVorname,
nachname = websiteNachname,
email = websiteEmail,
),
),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
DataCard("Vereinsdaten") {
OutlinedTextField(value = vereinName, onValueChange = { vereinName = it }, label = { Text("Vereinsname") }, modifier = Modifier.fillMaxWidth())
Row {
Checkbox(checked = useVorsitzenderAddress, onCheckedChange = { useVorsitzenderAddress = it })
Text("Adresse des 1. Vorsitzenden verwenden", modifier = Modifier.padding(top = 12.dp))
}
if (!useVorsitzenderAddress) {
OutlinedTextField(value = vereinStrasse, onValueChange = { vereinStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = vereinPlz, onValueChange = { vereinPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = vereinOrt, onValueChange = { vereinOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
}
item {
DataCard("Website") {
OutlinedTextField(value = websiteVorname, onValueChange = { websiteVorname = it }, label = { Text("Vorname") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = websiteNachname, onValueChange = { websiteNachname = it }, label = { Text("Nachname") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
}
}
item {
DataCard("Systemstatus") {
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
InfoRow("Trainer", config.trainer.size.toString())
InfoRow("Trainingszeiten", config.training.zeiten.size.toString())
}
}
item { FormMessages(state.error, state.message) }
}
}
}
}
@Composable
fun CmsBenutzerScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users)
CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users, viewModel)
}
@Composable
fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var searchTerm by remember { mutableStateOf("") }
var failedOnly by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
searchTerm = state.passwordResetSearchTerm
failedOnly = state.passwordResetFailedOnly
}
CmsPage(navController, showBackNavigation, "Passwort-Reset-Diagnose", "Fehlgeschlagene Reset-Versuche") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
if (!state.loading && state.passwordResetAttempts.isEmpty()) item { EmptyCard("Keine Diagnoseeinträge gefunden.") }
items(state.passwordResetAttempts.size) { index -> PasswordAttemptCard(state.passwordResetAttempts[index]) }
item {
DataCard("Filter") {
OutlinedTextField(
value = searchTerm,
onValueChange = { searchTerm = it },
label = { Text("E-Mail oder Name") },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.weight(1f)) {
Checkbox(checked = failedOnly, onCheckedChange = { failedOnly = it })
Text("Nur Auffälligkeiten", color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
Button(
onClick = { viewModel.loadPasswordResetDiagnostics(searchTerm, failedOnly) },
enabled = !state.loading,
) {
Text(if (state.loading) "Lädt..." else "Prüfen")
}
}
Text(
"Diagnoseeinträge werden nach ${state.passwordResetRetentionHours} Stunden automatisch gelöscht. E-Mail-Adressen sind maskiert.",
color = Accent500,
)
FormMessages(state.error, state.message)
}
}
if (state.loading) {
item { CircularProgressIndicator(color = Primary600) }
}
if (state.passwordResetSearchTerm.isNotBlank()) {
item {
DataCard("Passende Benutzerkonten") {
if (state.passwordResetMatchingUsers.isEmpty()) {
Text("Kein Login-Benutzer zur Suche gefunden.", color = Accent500)
} else {
state.passwordResetMatchingUsers.forEach { user ->
MatchingUserRow(
user = user,
onSearchLogs = {
searchTerm = user.email
viewModel.loadPasswordResetDiagnostics(user.email, failedOnly)
},
)
}
}
}
}
}
item {
DataCard("Reset-Vorgänge") {
InfoRow("Einträge", state.passwordResetAttempts.size.toString())
OutlinedButton(
onClick = { viewModel.loadPasswordResetDiagnostics(searchTerm, failedOnly) },
enabled = !state.loading,
) {
Text("Aktualisieren")
}
OutlinedButton(
onClick = {
val shareText = buildPasswordResetShareText(
attempts = state.passwordResetAttempts,
failedOnly = failedOnly,
searchTerm = state.passwordResetSearchTerm,
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "Passwort-Reset-Diagnose")
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(shareIntent, "Diagnose teilen"))
},
enabled = state.passwordResetAttempts.isNotEmpty(),
) {
Text("Export/Teilen")
}
}
}
if (!state.loading && state.passwordResetAttempts.isEmpty()) {
item { EmptyCard("Keine Diagnosevorgänge gefunden.") }
}
items(state.passwordResetAttempts.size) { index ->
PasswordAttemptCard(state.passwordResetAttempts[index])
}
}
}
@@ -394,6 +970,72 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
}
}
private val defaultHomepageSections = listOf(
HomepageSectionDto(id = "banner", enabled = true),
HomepageSectionDto(id = "aktuelles", enabled = true),
HomepageSectionDto(id = "termine", enabled = true),
HomepageSectionDto(id = "spiele", enabled = true),
HomepageSectionDto(id = "kontakt", enabled = true),
HomepageSectionDto(id = "training", enabled = false),
HomepageSectionDto(id = "links", enabled = false),
HomepageSectionDto(id = "vereinsmeisterschaften", enabled = false),
)
private val homepageSectionLabels = mapOf(
"banner" to ("Banner (Willkommen)" to "Hero-Bereich mit Willkommensnachricht"),
"aktuelles" to ("Aktuelles" to "Öffentliche News und Ankündigungen"),
"termine" to ("Kommende Termine" to "Vorschau der nächsten Vereinstermine"),
"spiele" to ("Nächste Spiele" to "Vorschau der kommenden Punktspiele"),
"kontakt" to ("Kontakt-Boxen" to "Mitglied werden und Kontakt aufnehmen"),
"training" to ("Training-Teaser" to "Direktzugang zu Training, Trainern und Anfängerbereich"),
"links" to ("Links-Teaser" to "Direktzugang zu nützlichen Vereinslinks"),
"vereinsmeisterschaften" to ("Vereinsmeisterschaften-Teaser" to "Direktzugang zu Meisterschaftsergebnissen"),
)
private fun normalizedHomepageSections(config: ConfigResponse?): List<HomepageSectionDto> {
val configured = config?.homepage?.sections.orEmpty().filter { it.id.isNotBlank() }
val knownIds = configured.map { it.id }.toMutableSet()
return buildList {
addAll(configured)
defaultHomepageSections.forEach { section ->
if (knownIds.add(section.id)) add(section)
}
}
}
@Composable
private fun HomepageSectionCard(
section: HomepageSectionDto,
index: Int,
lastIndex: Int,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onEnabledChange: (Boolean) -> Unit,
) {
val (label, description) = homepageSectionLabels[section.id]
?: (section.id to "Unbekanntes Element aus der bestehenden Konfiguration")
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
Text(description, color = Accent500)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onMoveUp, enabled = index > 0) { Text("Hoch") }
OutlinedButton(onClick = onMoveDown, enabled = index < lastIndex) { Text("Runter") }
}
Row {
Checkbox(checked = section.enabled, onCheckedChange = onEnabledChange)
Text("Anzeigen", color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
}
}
}
@Composable
fun CmsPage(
navController: NavController,
@@ -445,8 +1087,13 @@ private fun CmsConfigPage(
}
@Composable
private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) {
val viewModel: CmsViewModel = hiltViewModel()
private fun CmsUserListPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
users: List<CmsUserDto>,
viewModel: CmsViewModel,
) {
val state by viewModel.state.collectAsState()
var sortAsc by remember { mutableStateOf(true) }
@@ -460,7 +1107,7 @@ private fun CmsUserListPage(navController: NavController, showBackNavigation: Bo
.let { if (sortAsc) it else it.asReversed() }
CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") {
if (state.users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
// Sort controls
item {
@@ -540,8 +1187,7 @@ private fun UserCard(user: CmsUserDto, viewModel: CmsViewModel) {
}
@Composable
private fun ContactRequestCard(request: ContactRequestDto) {
val viewModel: CmsViewModel = hiltViewModel()
private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewModel) {
var replyOpen by remember { mutableStateOf(false) }
var replyText by remember { mutableStateOf("") }
val state by viewModel.state.collectAsState()
@@ -584,7 +1230,6 @@ private fun ContactRequestCard(request: ContactRequestDto) {
@Composable
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
val viewModel: CmsViewModel = hiltViewModel()
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
InfoRow("Erstellt", newsletter.createdAt ?: "-")
@@ -614,12 +1259,126 @@ private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGr
@Composable
private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) {
DataCard(attempt.emailMasked ?: attempt.requestId) {
InfoRow("Gestartet", attempt.startedAt ?: "-")
InfoRow("Status", if (attempt.failed) "Fehler" else "OK")
InfoRow("Schritte", attempt.steps.size.toString())
InfoRow("Status", if (attempt.failed) "Auffällig" else "Abgeschlossen")
InfoRow("Gestartet", formatDiagnosticsDateTime(attempt.startedAt))
InfoRow("IP", attempt.ip ?: "-")
attempt.steps.forEach { step ->
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(formatDiagnosticsTime(step.ts), color = Accent500)
Text(stepLabel(step.step), color = Accent900, fontWeight = FontWeight.SemiBold)
Text(statusLabel(step.status), color = stepStatusColor(step.status))
val detail = reasonLabel(step.reason).ifBlank { stepErrorLabel(step) }
if (detail.isNotBlank()) {
Text(detail, color = Accent700)
}
}
}
}
}
}
@Composable
private fun MatchingUserRow(user: PasswordResetMatchingUserDto, onSearchLogs: () -> Unit) {
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(user.name.ifBlank { "Unbenannter Benutzer" }, color = Accent900, fontWeight = FontWeight.SemiBold)
Text("${user.email} · ${if (user.active) "Aktiv" else "Nicht freigeschaltet"}", color = Accent500)
OutlinedButton(onClick = onSearchLogs) {
Text("Logs dieser Adresse")
}
}
}
}
private fun formatDiagnosticsDateTime(value: String?): String {
if (value.isNullOrBlank()) return "-"
return runCatching {
val parsed = OffsetDateTime.parse(value)
parsed.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss", Locale.GERMANY))
}.getOrElse { value }
}
private fun formatDiagnosticsTime(value: String?): String {
if (value.isNullOrBlank()) return "-"
return runCatching {
val parsed = OffsetDateTime.parse(value)
parsed.format(DateTimeFormatter.ofPattern("HH:mm:ss", Locale.GERMANY))
}.getOrElse { value }
}
private fun stepLabel(step: String): String = when (step) {
"request_received" -> "Anfrage"
"request_validation" -> "Validierung"
"rate_limit" -> "Rate Limit"
"user_lookup" -> "Benutzersuche"
"temporary_password" -> "Temporäres Passwort"
"password_storage" -> "Passwortspeicherung"
"session_revocation" -> "Sitzungen"
"mail_configuration" -> "Mail-Konfiguration"
"mail_send" -> "Mail-Versand"
"request_completed" -> "Abschluss"
else -> step
}
private fun statusLabel(status: String): String = when (status) {
"started" -> "Gestartet"
"checking" -> "Prüfung"
"passed" -> "OK"
"found" -> "Gefunden"
"not_found" -> "Nicht gefunden"
"generated" -> "Erzeugt"
"completed" -> "Erledigt"
"success" -> "Erfolgreich"
"no_account" -> "Kein Konto"
"failed" -> "Fehlgeschlagen"
else -> status
}
private fun stepStatusColor(status: String): Color = when (status) {
"failed", "not_found", "no_account" -> Color(0xFFB91C1C)
else -> Accent700
}
private fun reasonLabel(reason: String?): String = when (reason.orEmpty()) {
"email_missing" -> "E-Mail-Adresse fehlt"
"smtp_credentials_missing" -> "SMTP-Zugangsdaten fehlen"
"write_failed" -> "Passwort konnte nicht gespeichert werden"
else -> reason.orEmpty()
}
private fun stepErrorLabel(step: PasswordResetStepDto): String =
listOfNotNull(step.errorCode, step.errorMessage).joinToString(": ")
private fun buildPasswordResetShareText(
attempts: List<PasswordResetAttemptDto>,
failedOnly: Boolean,
searchTerm: String,
): String {
val header = buildString {
appendLine("Passwort-Reset-Diagnose")
appendLine("Filter: ${if (failedOnly) "Nur Auffälligkeiten" else "Alle"}")
appendLine("Suche: ${searchTerm.ifBlank { "-" }}")
appendLine("Einträge: ${attempts.size}")
appendLine()
}
val body = attempts.joinToString("\n\n") { attempt ->
buildString {
appendLine("Request: ${attempt.requestId}")
appendLine("Adresse: ${attempt.emailMasked ?: "-"}")
appendLine("Status: ${if (attempt.failed) "Auffällig" else "Abgeschlossen"}")
appendLine("Gestartet: ${formatDiagnosticsDateTime(attempt.startedAt)}")
appendLine("IP: ${attempt.ip ?: "-"}")
appendLine("Schritte:")
attempt.steps.forEach { step ->
val detail = reasonLabel(step.reason).ifBlank { stepErrorLabel(step) }
appendLine("- ${formatDiagnosticsTime(step.ts)} | ${stepLabel(step.step)} | ${statusLabel(step.status)}${if (detail.isNotBlank()) " | $detail" else ""}")
}
}
}
return header + body
}
@Composable
private fun DataCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
@@ -650,4 +1409,41 @@ private fun EmptyCard(message: String) {
}
}
@Composable
private fun TrainingTimeEditorCard(
zeit: de.harheimertc.data.TrainingTimeDto,
onChange: (de.harheimertc.data.TrainingTimeDto) -> Unit,
onRemove: () -> Unit,
) {
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = zeit.tag, onValueChange = { onChange(zeit.copy(tag = it)) }, label = { Text("Tag") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = zeit.von, onValueChange = { onChange(zeit.copy(von = it)) }, label = { Text("Von") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = zeit.bis, onValueChange = { onChange(zeit.copy(bis = it)) }, label = { Text("Bis") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(value = zeit.gruppe, onValueChange = { onChange(zeit.copy(gruppe = it)) }, label = { Text("Gruppe") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = zeit.info.orEmpty(), onValueChange = { onChange(zeit.copy(info = it)) }, label = { Text("Zusatzinfo") }, modifier = Modifier.fillMaxWidth())
OutlinedButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { Text("Entfernen") }
}
}
}
@Composable
private fun TrainerEditorCard(
trainer: de.harheimertc.data.TrainerDto,
onChange: (de.harheimertc.data.TrainerDto) -> Unit,
onRemove: () -> Unit,
) {
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = trainer.name, onValueChange = { onChange(trainer.copy(name = it)) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = trainer.lizenz, onValueChange = { onChange(trainer.copy(lizenz = it)) }, label = { Text("Lizenz") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = trainer.schwerpunkt, onValueChange = { onChange(trainer.copy(schwerpunkt = it)) }, label = { Text("Schwerpunkt") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = trainer.zusatz.orEmpty(), onValueChange = { onChange(trainer.copy(zusatz = it)) }, label = { Text("Zusatz") }, modifier = Modifier.fillMaxWidth())
OutlinedButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { Text("Entfernen") }
}
}
}
private fun textState(value: String): String = if (value.isBlank()) "Nicht gesetzt" else "${value.length} Zeichen"

View File

@@ -8,10 +8,12 @@ import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,7 +32,12 @@ data class CmsUiState(
val newsletters: List<NewsletterDto> = emptyList(),
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
val passwordResetMatchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
val passwordResetRetentionHours: Int = 72,
val passwordResetSearchTerm: String = "",
val passwordResetFailedOnly: Boolean = true,
val news: List<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(),
)
@HiltViewModel
@@ -54,7 +61,13 @@ class CmsViewModel @Inject constructor(
val newslettersRes = async { repository.newsletters() }
val groupsRes = async { repository.newsletterGroups() }
val newsRes = async { repository.news() }
val diagnosticsRes = async { repository.passwordResetDiagnostics() }
val diagnosticsRes = async {
repository.passwordResetDiagnostics(
email = _state.value.passwordResetSearchTerm.takeIf { it.isNotBlank() },
failedOnly = _state.value.passwordResetFailedOnly,
)
}
val meisterschaftenRes = async { repository.vereinsmeisterschaften() }
val configResult = configRes.await()
val usersResult = usersRes.await()
@@ -63,6 +76,7 @@ class CmsViewModel @Inject constructor(
val groupsResult = groupsRes.await()
val newsResult = newsRes.await()
val diagnosticsResult = diagnosticsRes.await()
val meisterschaftenResult = meisterschaftenRes.await()
val errors = listOfNotNull(
ErrorMapper.mapError(configResult.exceptionOrNull()),
@@ -72,6 +86,7 @@ class CmsViewModel @Inject constructor(
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
ErrorMapper.mapError(newsResult.exceptionOrNull()),
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
ErrorMapper.mapError(meisterschaftenResult.exceptionOrNull()),
)
// Sort users so that pending (inactive) users come first,
@@ -92,10 +107,67 @@ class CmsViewModel @Inject constructor(
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
news = newsResult.getOrNull()?.news.orEmpty(),
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
passwordResetMatchingUsers = diagnosticsResult.getOrNull()?.matchingUsers.orEmpty(),
passwordResetRetentionHours = diagnosticsResult.getOrNull()?.retentionHours ?: 72,
passwordResetSearchTerm = diagnosticsResult.getOrNull()?.searchedEmail.orEmpty(),
passwordResetFailedOnly = _state.value.passwordResetFailedOnly,
meisterschaften = meisterschaftenResult.getOrNull().orEmpty(),
)
}
}
fun loadPasswordResetDiagnostics(email: String, failedOnly: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(
loading = true,
error = null,
passwordResetSearchTerm = email.trim(),
passwordResetFailedOnly = failedOnly,
)
repository.passwordResetDiagnostics(
email = email.trim().takeIf { it.isNotBlank() },
failedOnly = failedOnly,
)
.onSuccess { response ->
_state.value = _state.value.copy(
loading = false,
passwordResetRetentionHours = response.retentionHours,
passwordResetMatchingUsers = response.matchingUsers,
passwordResetAttempts = response.attempts,
passwordResetSearchTerm = response.searchedEmail.orEmpty(),
)
}
.onFailure { err ->
_state.value = _state.value.copy(
loading = false,
passwordResetMatchingUsers = emptyList(),
passwordResetAttempts = emptyList(),
error = ErrorMapper.mapError(err) ?: "Passwort-Reset-Diagnose konnte nicht geladen werden.",
)
}
}
}
fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.saveVereinsmeisterschaften(results)
.onSuccess { response ->
_state.value = _state.value.copy(
saving = false,
meisterschaften = results,
message = response.message ?: "Vereinsmeisterschaften gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
saving = false,
error = ErrorMapper.mapError(err) ?: "Vereinsmeisterschaften konnten nicht gespeichert werden.",
)
}
}
}
fun saveConfig(config: ConfigResponse) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)

View File

@@ -21,16 +21,20 @@ import androidx.compose.material3.AlertDialog
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -49,7 +53,9 @@ import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.AppNavigationHeader
@@ -77,6 +83,7 @@ fun HomeScreen(
val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
var editHomeSections by rememberSaveable { mutableStateOf(false) }
selectedNews?.let { item ->
AlertDialog(
@@ -104,36 +111,345 @@ fun HomeScreen(
)
}
}
item { WebHero() }
item {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
HomeCustomizationSection(
sections = state.homepageSections,
spielplanSeasons = state.spielplanSeasons,
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
editEnabled = editHomeSections,
onToggleEdit = { editHomeSections = !editHomeSections },
onMoveUp = viewModel::moveSectionUp,
onMoveDown = viewModel::moveSectionDown,
onEnabledChange = viewModel::setSectionEnabled,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
onReset = viewModel::resetSections,
)
}
item {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section)
when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
"termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
)
}
"spiele" -> item(key = "home_section_${sectionKey}_$index") {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
"aktuelles" -> {
if (state.news.isNotEmpty()) {
item(key = "home_section_${sectionKey}_$index") {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
)
}
}
}
"kontakt" -> item(key = "home_section_${sectionKey}_$index") {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
"training" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Training & Einstieg",
body = "Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot.",
action = "Zum Training",
onClick = { navController.navigate(Destinations.Training.route) },
)
}
"links" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Nützliche Links",
body = "Direkter Zugang zu Verbänden, Ergebnisdiensten und hilfreichen Portalen.",
action = "Links öffnen",
onClick = { navController.navigate(Destinations.Links.route) },
)
}
"vereinsmeisterschaften" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Vereinsmeisterschaften",
body = "Ergebnisse und Historie unserer Vereinsmeisterschaften.",
action = "Ergebnisse ansehen",
onClick = { navController.navigate(Destinations.Vereinsmeisterschaften.route) },
)
}
"spielplan_team" -> item(key = "home_section_${sectionKey}_$index") {
HomeSpielplanTeamWidgetSection(
section = section,
spiele = state.spielplanWidgetPreviews[sectionKey].orEmpty(),
error = state.spielplanWidgetErrors[sectionKey],
loading = state.widgetsLoading,
onOpenAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
}
}
if (state.news.isNotEmpty()) {
item {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
item { HomeFooter() }
}
}
@Composable
private fun HomeCustomizationSection(
sections: List<HomepageSectionDto>,
spielplanSeasons: List<SeasonDto>,
spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
editEnabled: Boolean,
onToggleEdit: () -> Unit,
onMoveUp: (String) -> Unit,
onMoveDown: (String) -> Unit,
onEnabledChange: (String, Boolean) -> Unit,
onAddSpielplanWidget: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
onUpdateSpielplanWidget: (sectionKey: String, season: String, teamName: String, teamAgeGroup: String) -> Unit,
onReset: () -> Unit,
) {
var addSeason by rememberSaveable { mutableStateOf("") }
var addTeamKey by rememberSaveable { mutableStateOf("") }
val addTeamOptions = spielplanTeamsBySeason[addSeason].orEmpty()
LaunchedEffect(spielplanSeasons) {
if (addSeason.isBlank() || spielplanSeasons.none { it.slug == addSeason }) {
addSeason = spielplanSeasons.firstOrNull()?.slug.orEmpty()
}
}
LaunchedEffect(addSeason, addTeamOptions) {
if (addTeamOptions.none { teamOptionKey(it) == addTeamKey }) {
addTeamKey = addTeamOptions.firstOrNull()?.let(::teamOptionKey).orEmpty()
}
}
Surface(
color = Color(0xFFFAFAFA),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedButton(onClick = onToggleEdit) {
Text(if (editEnabled) "Startseiten-Editor schließen" else "Startseite anpassen")
}
if (editEnabled) {
Text(
"Elemente ein-/ausblenden und Reihenfolge festlegen.",
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
)
sections.forEachIndexed { index, section ->
val label = homeSectionLabels[section.id] ?: section.id
val sectionKey = homeSectionKey(section)
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
Text(section.id, style = MaterialTheme.typography.labelSmall, color = Accent500)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Anzeigen", color = Accent700, style = MaterialTheme.typography.labelSmall)
androidx.compose.material3.Checkbox(
checked = section.enabled,
onCheckedChange = { enabled -> onEnabledChange(sectionKey, enabled) },
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(onClick = { onMoveUp(sectionKey) }, enabled = index > 0) { Text("Hoch") }
OutlinedButton(onClick = { onMoveDown(sectionKey) }, enabled = index < sections.lastIndex) { Text("Runter") }
}
}
if (section.id == "spielplan_team") {
SpielplanWidgetConfigEditor(
section = section,
seasons = spielplanSeasons,
teamsBySeason = spielplanTeamsBySeason,
onUpdate = { season, teamName, teamAgeGroup ->
onUpdateSpielplanWidget(sectionKey, season, teamName, teamAgeGroup)
},
)
}
}
}
}
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Widget hinzufügen", style = MaterialTheme.typography.titleMedium, color = Accent900)
Text("Spielplan Mannschaft", style = MaterialTheme.typography.bodyMedium, color = Accent700)
SimpleSelector(
label = "Saison",
selected = spielplanSeasons.firstOrNull { it.slug == addSeason }?.let { formatSeasonLabel(it.slug) }
?: "Bitte wählen",
options = spielplanSeasons.map { season ->
SelectOption(
key = season.slug,
label = formatSeasonLabel(season.slug),
)
},
onSelect = { option ->
addSeason = option.key
},
)
SimpleSelector(
label = "Mannschaft",
selected = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey }?.label ?: "Bitte wählen",
options = addTeamOptions.map { team ->
SelectOption(
key = teamOptionKey(team),
label = team.label,
)
},
onSelect = { option ->
addTeamKey = option.key
},
)
Button(
onClick = {
val selectedTeam = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey } ?: return@Button
onAddSpielplanWidget(addSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
},
enabled = addSeason.isNotBlank() && addTeamKey.isNotBlank(),
) {
Text("Widget hinzufügen")
}
}
}
TextButton(onClick = onReset) {
Text("Auf Server-Standard zurücksetzen")
}
}
}
}
}
private data class SelectOption(
val key: String,
val label: String,
)
@Composable
private fun SimpleSelector(
label: String,
selected: String,
options: List<SelectOption>,
onSelect: (SelectOption) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
OutlinedButton(onClick = { expanded = true }, enabled = options.isNotEmpty()) {
Text(selected)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
item {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
}
@Composable
private fun SpielplanWidgetConfigEditor(
section: HomepageSectionDto,
seasons: List<SeasonDto>,
teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
onUpdate: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
) {
val selectedSeason = section.config?.season.orEmpty()
val selectedTeamName = section.config?.teamName.orEmpty()
val selectedTeamAgeGroup = section.config?.teamAgeGroup.orEmpty()
val teamOptions = teamsBySeason[selectedSeason].orEmpty()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SimpleSelector(
label = "Saison",
selected = seasons.firstOrNull { it.slug == selectedSeason }?.let { formatSeasonLabel(it.slug) } ?: "Bitte wählen",
options = seasons.map { season -> SelectOption(season.slug, formatSeasonLabel(season.slug)) },
onSelect = { option ->
val fallbackTeam = teamsBySeason[option.key].orEmpty().firstOrNull()
onUpdate(
option.key,
fallbackTeam?.teamName ?: "",
fallbackTeam?.teamAgeGroup ?: "",
)
},
)
SimpleSelector(
label = "Mannschaft",
selected = teamOptions.firstOrNull {
it.teamName == selectedTeamName && it.teamAgeGroup == selectedTeamAgeGroup
}?.label ?: "Bitte wählen",
options = teamOptions.map { team -> SelectOption(teamOptionKey(team), team.label) },
onSelect = { option ->
val selectedTeam = teamOptions.firstOrNull { teamOptionKey(it) == option.key }
if (selectedTeam != null) {
onUpdate(selectedSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
}
},
)
}
}
@Composable
private fun HomeSpielplanTeamWidgetSection(
section: HomepageSectionDto,
spiele: List<SpielDto>,
error: String?,
loading: Boolean,
onOpenAll: () -> Unit,
) {
val teamName = section.config?.teamName.orEmpty()
val teamAgeGroup = section.config?.teamAgeGroup.orEmpty()
val title = if (teamAgeGroup.contains("jugend", ignoreCase = true) && teamName.isNotBlank()) {
"Spielplan: (J) $teamName"
} else {
"Spielplan: ${teamName.ifBlank { "Mannschaft" }}"
}
val season = section.config?.season.orEmpty()
HomeSection(title = title, subtitle = "Saison ${formatSeasonLabel(season)}", background = Color.White) {
if (loading) {
LoadingRow("Spiele werden geladen...")
} else if (!error.isNullOrBlank()) {
EmptyRow(error)
} else if (spiele.isEmpty()) {
EmptyRow("Keine kommenden Spiele für diese Mannschaft gefunden.")
} else {
spiele.forEach { spiel -> MatchCard(spiel) }
}
item { HomeFooter() }
PrimaryAction("Voller Spielplan", onOpenAll)
}
}
@@ -251,6 +567,18 @@ private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
}
}
@Composable
private fun HomeExtraActionSection(title: String, body: String, action: String, onClick: () -> Unit) {
HomeSection(title = null, background = Color.White) {
ActionCard(
title = title,
body = body,
action = action,
onClick = onClick,
)
}
}
@Composable
private fun HomeSection(
title: String?,
@@ -427,3 +755,27 @@ private fun formatNewsDate(value: String?): String {
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
}.getOrDefault(value.take(10))
}
private fun homeSectionKey(section: HomepageSectionDto): String =
section.key?.takeIf { it.isNotBlank() } ?: section.id
private fun teamOptionKey(option: HomeSpielplanTeamOption): String =
"${option.teamName}|${option.teamAgeGroup}"
private fun formatSeasonLabel(value: String): String {
val match = Regex("^(\\d{2})--(\\d{2})$").find(value)
if (match == null) return value.ifBlank { "-" }
return "20${match.groupValues[1]}/${match.groupValues[2]}"
}
private val homeSectionLabels = mapOf(
"banner" to "Banner (Willkommen)",
"aktuelles" to "Aktuelles",
"termine" to "Kommende Termine",
"spiele" to "Nächste Spiele",
"kontakt" to "Kontakt-Boxen",
"training" to "Training-Teaser",
"links" to "Links-Teaser",
"vereinsmeisterschaften" to "Vereinsmeisterschaften-Teaser",
"spielplan_team" to "Widget: Spielplan Mannschaft",
)

View File

@@ -3,30 +3,58 @@ package de.harheimertc.ui.screens.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.HomepageSectionConfigDto
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.HomeLayoutPreferences
import de.harheimertc.repositories.HomeRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class HomeSpielplanTeamOption(
val teamName: String,
val teamAgeGroup: String,
) {
val label: String
get() = if (teamAgeGroup.contains("jugend", ignoreCase = true)) {
"(J) $teamName"
} else {
teamName
}
}
data class HomeUiState(
val loading: Boolean = true,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
val homepageSections: List<HomepageSectionDto> = defaultHomepageSections,
val spielplanSeasons: List<SeasonDto> = emptyList(),
val spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>> = emptyMap(),
val spielplanWidgetPreviews: Map<String, List<SpielDto>> = emptyMap(),
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false,
val error: Boolean = false,
)
@HiltViewModel
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
class HomeViewModel @Inject constructor(
private val repository: HomeRepository,
private val layoutPreferences: HomeLayoutPreferences,
) : ViewModel() {
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state
private var serverSections: List<HomepageSectionDto> = defaultHomepageSections
private val seasonGamesCache = mutableMapOf<String, List<SpielDto>>()
init {
load()
@@ -37,6 +65,19 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
_state.value = _state.value.copy(loading = true, error = false)
repository.fetchHomeData()
.onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections)
val sections = mergeWithUserSections(
server = serverSections,
user = layoutPreferences.getSections(),
)
seasonGamesCache.clear()
data.selectedSpielplanSeason?.takeIf { it.isNotBlank() }?.let { season ->
seasonGamesCache[season] = data.spiele
}
val widgetData = loadWidgetData(
sections = sections,
seasons = data.spielplanSeasons,
)
_state.value = HomeUiState(
loading = false,
termine = data.termine
@@ -53,6 +94,11 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
.sortedBy { it.asDate() }
.take(3),
news = data.news.take(3),
homepageSections = sections,
spielplanSeasons = widgetData.seasons,
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
)
}
.onFailure {
@@ -60,8 +106,189 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
}
}
}
fun moveSectionUp(sectionKey: String) {
updateSections { sections ->
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
if (index <= 0) return@updateSections sections
sections.toMutableList().also { list ->
val current = list.removeAt(index)
list.add(index - 1, current)
}
}
}
fun moveSectionDown(sectionKey: String) {
updateSections { sections ->
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
if (index < 0 || index >= sections.lastIndex) return@updateSections sections
sections.toMutableList().also { list ->
val current = list.removeAt(index)
list.add(index + 1, current)
}
}
}
fun setSectionEnabled(sectionKey: String, enabled: Boolean) {
updateSections { sections ->
sections.map { section ->
if (sectionKey(section) == sectionKey) section.copy(enabled = enabled) else section
}
}
}
fun addSpielplanTeamWidget(season: String, teamName: String, teamAgeGroup: String) {
val normalizedSeason = season.trim()
val normalizedTeamName = teamName.trim()
if (normalizedSeason.isBlank() || normalizedTeamName.isBlank()) return
val newSection = HomepageSectionDto(
id = WIDGET_SECTION_ID,
key = "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
enabled = true,
config = HomepageSectionConfigDto(
season = normalizedSeason,
teamName = normalizedTeamName,
teamAgeGroup = teamAgeGroup.trim(),
),
)
updateSections { sections -> sections + newSection }
refreshWidgetData()
}
fun updateSpielplanTeamWidget(
sectionKey: String,
season: String,
teamName: String,
teamAgeGroup: String,
) {
updateSections { sections ->
sections.map { section ->
if (sectionKey(section) != sectionKey) return@map section
section.copy(
config = HomepageSectionConfigDto(
season = season.trim(),
teamName = teamName.trim(),
teamAgeGroup = teamAgeGroup.trim(),
),
)
}
}
refreshWidgetData()
}
fun resetSections() {
val reset = serverSections
layoutPreferences.clearSections()
_state.value = _state.value.copy(homepageSections = reset)
refreshWidgetData()
}
private fun updateSections(transform: (List<HomepageSectionDto>) -> List<HomepageSectionDto>) {
val updated = transform(_state.value.homepageSections)
if (updated == _state.value.homepageSections) return
layoutPreferences.setSections(updated)
_state.value = _state.value.copy(homepageSections = updated)
}
private fun refreshWidgetData() {
viewModelScope.launch {
_state.value = _state.value.copy(widgetsLoading = true)
val widgetData = loadWidgetData(
sections = _state.value.homepageSections,
seasons = _state.value.spielplanSeasons,
)
_state.value = _state.value.copy(
spielplanSeasons = widgetData.seasons,
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
widgetsLoading = false,
)
}
}
private suspend fun loadWidgetData(
sections: List<HomepageSectionDto>,
seasons: List<SeasonDto>,
): HomeWidgetData {
val allSeasons = seasons
.filter { it.slug.isNotBlank() }
.distinctBy { it.slug }
.toMutableList()
seasonGamesCache.keys.forEach { slug ->
if (allSeasons.none { it.slug == slug }) {
allSeasons += SeasonDto(slug = slug, label = slug)
}
}
val neededWidgetSeasons = sections
.asSequence()
.filter { it.id == WIDGET_SECTION_ID }
.mapNotNull { it.config?.season?.takeIf(String::isNotBlank) }
.toSet()
allSeasons.forEach { season ->
ensureSeasonLoaded(season.slug)
}
neededWidgetSeasons.forEach { season ->
if (allSeasons.none { it.slug == season }) {
allSeasons += SeasonDto(slug = season, label = season)
}
ensureSeasonLoaded(season)
}
val teamsBySeason = buildMap {
allSeasons.forEach { season ->
val games = seasonGamesCache[season.slug] ?: return@forEach
put(season.slug, extractHarheimerTeams(games))
}
}
val previews = mutableMapOf<String, List<SpielDto>>()
val errors = mutableMapOf<String, String>()
sections.forEach { section ->
if (section.id != WIDGET_SECTION_ID) return@forEach
val key = sectionKey(section)
val config = section.config
val season = config?.season.orEmpty()
val teamName = config?.teamName.orEmpty()
if (season.isBlank() || teamName.isBlank()) {
errors[key] = "Bitte Saison und Mannschaft wählen."
previews[key] = emptyList()
return@forEach
}
val games = seasonGamesCache[season]
if (games == null) {
errors[key] = "Spielplan konnte nicht geladen werden."
previews[key] = emptyList()
return@forEach
}
previews[key] = filterUpcomingTeamGames(games, teamName, config?.teamAgeGroup.orEmpty())
}
return HomeWidgetData(
seasons = allSeasons,
teamsBySeason = teamsBySeason,
previewGamesBySectionKey = previews,
errorsBySectionKey = errors,
)
}
private suspend fun ensureSeasonLoaded(season: String) {
if (seasonGamesCache.containsKey(season)) return
repository.fetchSpielplanForSeason(season).onSuccess { response ->
seasonGamesCache[season] = response.data
}
}
}
private data class HomeWidgetData(
val seasons: List<SeasonDto>,
val teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
val previewGamesBySectionKey: Map<String, List<SpielDto>>,
val errorsBySectionKey: Map<String, String>,
)
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"))
@@ -70,3 +297,110 @@ private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
fun SpielDto.asDate(): LocalDate? = runCatching {
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.getOrNull()
private val defaultHomepageSections = listOf(
HomepageSectionDto(id = "banner", key = "banner", enabled = true),
HomepageSectionDto(id = "aktuelles", key = "aktuelles", enabled = true),
HomepageSectionDto(id = "termine", key = "termine", enabled = true),
HomepageSectionDto(id = "spiele", key = "spiele", enabled = true),
HomepageSectionDto(id = "kontakt", key = "kontakt", enabled = true),
HomepageSectionDto(id = "training", key = "training", enabled = false),
HomepageSectionDto(id = "links", key = "links", enabled = false),
HomepageSectionDto(id = "vereinsmeisterschaften", key = "vereinsmeisterschaften", enabled = false),
)
private fun normalizedHomepageSections(configuredSections: List<HomepageSectionDto>): List<HomepageSectionDto> {
val configured = configuredSections
.filter { it.id.isNotBlank() }
.mapIndexed { index, section ->
val fallback = if (section.id == WIDGET_SECTION_ID) "${section.id}_${index + 1}" else section.id
section.copy(key = section.key?.takeIf { it.isNotBlank() } ?: fallback)
}
val knownIds = configured.map { it.id }.toMutableSet()
return buildList {
addAll(configured)
defaultHomepageSections.forEach { section ->
if (knownIds.add(section.id)) add(section)
}
}
}
private fun mergeWithUserSections(
server: List<HomepageSectionDto>,
user: List<HomepageSectionDto>?,
): List<HomepageSectionDto> {
if (user.isNullOrEmpty()) return server
val serverById = server.associateBy { it.id }
val serverByKey = server.associateBy { sectionKey(it) }
val ordered = buildList<HomepageSectionDto> {
user.forEach { userSection ->
val matchedServerSection = serverByKey[sectionKey(userSection)]
?: if (userSection.id == WIDGET_SECTION_ID) null else serverById[userSection.id]
if (matchedServerSection != null) {
if (none { sectionKey(it) == sectionKey(matchedServerSection) }) {
add(
matchedServerSection.copy(
enabled = userSection.enabled,
key = sectionKey(userSection),
config = userSection.config,
),
)
}
return@forEach
}
if (userSection.id == WIDGET_SECTION_ID && none { sectionKey(it) == sectionKey(userSection) }) {
add(
userSection.copy(
key = userSection.key?.takeIf { it.isNotBlank() }
?: "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
),
)
}
}
server.forEach { serverSection ->
if (none { sectionKey(it) == sectionKey(serverSection) }) add(serverSection)
}
}
return ordered.ifEmpty { server }
}
private fun sectionKey(section: HomepageSectionDto): String =
section.key?.takeIf { it.isNotBlank() } ?: section.id
private fun extractHarheimerTeams(games: List<SpielDto>): List<HomeSpielplanTeamOption> =
games
.flatMap { game ->
listOf(
HomeSpielplanTeamOption(game.heimMannschaft.trim(), game.heimAltersklasse.trim()),
HomeSpielplanTeamOption(game.gastMannschaft.trim(), game.gastAltersklasse.trim()),
)
}
.filter { option -> option.teamName.contains("Harheimer TC", ignoreCase = true) }
.filter { option -> option.teamName.isNotBlank() }
.distinctBy { "${it.teamName}|${it.teamAgeGroup}" }
.sortedBy { it.label }
private fun filterUpcomingTeamGames(
games: List<SpielDto>,
teamName: String,
teamAgeGroup: String,
): List<SpielDto> {
val normalizedTeam = teamName.trim()
val normalizedAgeGroup = teamAgeGroup.trim()
val today = LocalDate.now()
return games
.asSequence()
.filter { game ->
val homeMatch = game.heimMannschaft.trim() == normalizedTeam &&
(normalizedAgeGroup.isBlank() || game.heimAltersklasse.trim() == normalizedAgeGroup)
val awayMatch = game.gastMannschaft.trim() == normalizedTeam &&
(normalizedAgeGroup.isBlank() || game.gastAltersklasse.trim() == normalizedAgeGroup)
homeMatch || awayMatch
}
.filter { game -> game.asDate()?.let { !it.isBefore(today) } == true }
.sortedBy { it.asDate() }
.take(5)
.toList()
}
private const val WIDGET_SECTION_ID = "spielplan_team"

View File

@@ -38,7 +38,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C"))))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
val vm = CmsViewModel(repo)
// advance init launched coroutine
@@ -61,7 +62,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
val vm = CmsViewModel(repo)
@@ -89,7 +91,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
val vm = CmsViewModel(repo)
@@ -113,7 +116,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
@@ -140,7 +144,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
@@ -167,7 +172,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
<template>
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-gray-50 rounded-xl shadow-sm p-8 md:p-10 border border-gray-200">
<h2 class="text-2xl md:text-3xl font-display font-bold text-gray-900 mb-3">
Nützliche Links
</h2>
<p class="text-gray-600 mb-6 max-w-3xl">
Direkter Zugang zu Verbänden, Ergebnisdiensten und weiteren hilfreichen Portalen.
</p>
<NuxtLink
to="/links"
class="inline-flex items-center px-6 py-3 rounded-lg border border-primary-600 text-primary-700 hover:bg-primary-50 font-semibold transition-colors"
>
Links öffnen
</NuxtLink>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,187 @@
<template>
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-gray-50 rounded-xl border border-gray-200 p-6 md:p-8">
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900">
Spielplan: {{ widgetTitle }}
</h2>
<p class="text-sm text-gray-600 mt-1">
Saison {{ seasonLabel }}
</p>
</div>
<NuxtLink
to="/spielplan"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 hover:bg-primary-700 text-white text-sm font-semibold transition-colors"
>
Voller Spielplan
</NuxtLink>
</div>
<div
v-if="isLoading"
class="text-sm text-gray-600"
>
Spiele werden geladen...
</div>
<div
v-else-if="error"
class="text-sm text-red-700"
>
{{ error }}
</div>
<div
v-else-if="upcomingGames.length === 0"
class="text-sm text-gray-600"
>
Keine kommenden Spiele für diese Mannschaft gefunden.
</div>
<div
v-else
class="space-y-3"
>
<div
v-for="game in upcomingGames"
:key="`${game.Termin}-${game.HeimMannschaft}-${game.GastMannschaft}`"
class="bg-white rounded-lg border border-gray-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<p class="text-sm font-medium text-gray-900">
{{ formatDate(game.Termin) }} {{ formatTime(game.Termin) }}
</p>
<p class="text-xs text-gray-500">
{{ game.Runde || '-' }}
</p>
</div>
<p class="text-sm text-gray-800">
{{ game.HeimMannschaft }} vs {{ game.GastMannschaft }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
season: {
type: String,
default: ''
},
teamName: {
type: String,
default: ''
},
teamAgeGroup: {
type: String,
default: ''
}
})
const isLoading = ref(false)
const error = ref('')
const games = ref([])
const widgetTitle = computed(() => {
if (!props.teamName) return 'Mannschaft'
const youth = String(props.teamAgeGroup || '').toLowerCase().includes('jugend')
return youth ? `(J) ${props.teamName}` : props.teamName
})
const seasonLabel = computed(() => {
const match = String(props.season || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return props.season || '-'
return `20${match[1]}/${match[2]}`
})
const upcomingGames = computed(() => {
const now = new Date()
now.setHours(0, 0, 0, 0)
return games.value
.filter(game => {
const gameDate = parseDate(game.Termin)
return gameDate && gameDate >= now
})
.sort((a, b) => parseDate(a.Termin) - parseDate(b.Termin))
.slice(0, 5)
})
function parseDate(termin) {
const raw = String(termin || '').trim()
const datePart = raw.split(' ')[0]
const [day, month, year] = datePart.split('.')
if (!day || !month || !year) return null
const parsed = new Date(Number(year), Number(month) - 1, Number(day))
if (Number.isNaN(parsed.getTime())) return null
parsed.setHours(0, 0, 0, 0)
return parsed
}
function formatDate(termin) {
const parsed = parseDate(termin)
if (!parsed) return termin || '-'
return parsed.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function formatTime(termin) {
const raw = String(termin || '')
const timePart = raw.split(' ')[1]
return timePart || ''
}
function isConfiguredTeamMatch(game) {
const teamName = String(props.teamName || '').trim()
const teamAgeGroup = String(props.teamAgeGroup || '').trim()
if (!teamName) return false
const homeMatch = String(game.HeimMannschaft || '').trim() === teamName &&
(!teamAgeGroup || String(game.HeimMannschaftAltersklasse || '').trim() === teamAgeGroup)
const awayMatch = String(game.GastMannschaft || '').trim() === teamName &&
(!teamAgeGroup || String(game.GastMannschaftAltersklasse || '').trim() === teamAgeGroup)
return homeMatch || awayMatch
}
async function loadData() {
if (!props.teamName || !props.season) {
games.value = []
return
}
isLoading.value = true
error.value = ''
try {
const result = await $fetch('/api/spielplan', {
query: { season: props.season }
})
if (!result?.success) {
throw new Error(result?.message || 'Spielplan konnte nicht geladen werden.')
}
games.value = (result.data || []).filter(isConfiguredTeamMatch)
} catch (err) {
games.value = []
error.value = err?.data?.message || err?.message || 'Spielplan konnte nicht geladen werden.'
} finally {
isLoading.value = false
}
}
watch(
() => [props.season, props.teamName, props.teamAgeGroup],
() => {
loadData()
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,20 @@
<template>
<section class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8 md:p-10 border border-gray-100">
<h2 class="text-2xl md:text-3xl font-display font-bold text-gray-900 mb-3">
Training & Einstieg
</h2>
<p class="text-gray-600 mb-6 max-w-3xl">
Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot auf einen Blick.
</p>
<NuxtLink
to="/training"
class="inline-flex items-center px-6 py-3 rounded-lg bg-primary-600 hover:bg-primary-700 text-white font-semibold transition-colors"
>
Zum Training
</NuxtLink>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<section class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8 md:p-10 border border-gray-100">
<h2 class="text-2xl md:text-3xl font-display font-bold text-gray-900 mb-3">
Vereinsmeisterschaften
</h2>
<p class="text-gray-600 mb-6 max-w-3xl">
Ergebnisse, Historie und Einblicke in die Vereinsmeisterschaften.
</p>
<NuxtLink
to="/vereinsmeisterschaften"
class="inline-flex items-center px-6 py-3 rounded-lg bg-primary-600 hover:bg-primary-700 text-white font-semibold transition-colors"
>
Ergebnisse ansehen
</NuxtLink>
</div>
</div>
</section>
</template>

View File

@@ -7,8 +7,12 @@
>
<div class="pointer-events-auto mx-4 shadow-lg rounded-md overflow-hidden">
<div class="px-4 py-3 bg-green-50 text-green-800 text-sm">
<div class="font-medium">{{ toastTitle }}</div>
<div class="mt-1">{{ toastMessage }}</div>
<div class="font-medium">
{{ toastTitle }}
</div>
<div class="mt-1">
{{ toastMessage }}
</div>
</div>
</div>
</div>

View File

@@ -33,7 +33,10 @@
<!-- Tab Content -->
<div>
<CmsTermine v-if="activeTab === 'termine'" />
<CmsMannschaften ref="cmsMannschaftenRef" v-if="activeTab === 'mannschaften'" />
<CmsMannschaften
v-if="activeTab === 'mannschaften'"
ref="cmsMannschaftenRef"
/>
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
</div>
</div>

View File

@@ -46,7 +46,7 @@
Verfügbare Elemente
</h2>
<p class="text-sm text-gray-600">
Ziehen Sie die Elemente per Drag & Drop oder verwenden Sie die Pfeil-Buttons, um die Reihenfolge zu ändern.
Legen Sie Reihenfolge, Sichtbarkeit und Marker fest (ohne Marker, cookie, eingeloggt).
</p>
</div>
@@ -110,13 +110,33 @@
</span>
</label>
</div>
<!-- Marker -->
<div class="flex items-center">
<label class="text-sm text-gray-700 mr-2">Marker</label>
<select
v-model="section.marker"
class="px-2 py-1 border border-gray-300 rounded-lg text-sm bg-white"
:disabled="isSaving"
>
<option value="">
keiner
</option>
<option value="cookie">
cookie
</option>
<option value="eingeloggt">
eingeloggt
</option>
</select>
</div>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-sm text-blue-800">
<strong>Hinweis:</strong> Deaktivierte Elemente werden auf der Startseite nicht angezeigt, bleiben aber in der Konfiguration erhalten.
<strong>Hinweis:</strong> Marker steuern die Sichtbarkeit auf der Web-Startseite: cookie zeigt das Element bei vorhandenen Cookies, eingeloggt nur für angemeldete Nutzer.
</p>
</div>
@@ -166,6 +186,30 @@ const availableSections = {
kontakt: {
label: 'Kontakt-Boxen',
description: 'Mitglied werden & Kontakt aufnehmen'
},
training: {
label: 'Training-Teaser',
description: 'Direktzugang zu Training, Trainern und Anfängerbereich'
},
links: {
label: 'Links-Teaser',
description: 'Direktzugang zu den nützlichen Vereinslinks'
},
vereinsmeisterschaften: {
label: 'Vereinsmeisterschaften-Teaser',
description: 'Direktzugang zu Meisterschaftsergebnissen'
}
}
function normalizeMarker(marker) {
return marker === 'cookie' || marker === 'eingeloggt' ? marker : ''
}
function normalizeSection(section) {
return {
id: section?.id,
enabled: section?.enabled !== false,
marker: normalizeMarker(section?.marker)
}
}
@@ -185,17 +229,23 @@ const loadConfig = async () => {
// Standard-Reihenfolge, falls nicht vorhanden
const defaultSections = [
{ id: 'banner', enabled: true },
{ id: 'termine', enabled: true },
{ id: 'spiele', enabled: true },
{ id: 'aktuelles', enabled: true },
{ id: 'kontakt', enabled: true }
{ id: 'banner', enabled: true, marker: '' },
{ id: 'termine', enabled: true, marker: '' },
{ id: 'spiele', enabled: true, marker: '' },
{ id: 'aktuelles', enabled: true, marker: '' },
{ id: 'kontakt', enabled: true, marker: '' },
{ id: 'training', enabled: false, marker: '' },
{ id: 'links', enabled: false, marker: '' },
{ id: 'vereinsmeisterschaften', enabled: false, marker: '' }
]
if (config.homepage && config.homepage.sections && Array.isArray(config.homepage.sections)) {
// Validiere und merge: Nur bekannte IDs verwenden, fehlende hinzufügen
const knownIds = new Set(config.homepage.sections.map(s => s.id))
const merged = [...config.homepage.sections]
const normalized = config.homepage.sections
.filter(s => s?.id)
.map(normalizeSection)
const knownIds = new Set(normalized.map(s => s.id))
const merged = [...normalized]
// Füge fehlende Standard-Elemente hinzu
for (const defaultSection of defaultSections) {
@@ -204,9 +254,9 @@ const loadConfig = async () => {
}
}
sections.value = merged
sections.value = merged.map(normalizeSection)
} else {
sections.value = [...defaultSections]
sections.value = defaultSections.map(normalizeSection)
}
} catch (error) {
console.error('Fehler beim Laden der Konfiguration:', error)
@@ -242,7 +292,7 @@ const saveConfig = async () => {
if (!config.homepage) {
config.homepage = {}
}
config.homepage.sections = sections.value
config.homepage.sections = sections.value.map(normalizeSection)
// Speichere Config
await $fetch('/api/config', {

View File

@@ -1,55 +1,562 @@
<template>
<div class="min-h-full">
<component
:is="getComponentForSection(section.id)"
<div
v-if="canCustomizeHome"
class="fixed right-4 bottom-14 z-[60]"
>
<button
type="button"
class="w-9 h-9 rounded-full bg-white border border-gray-200 shadow-sm hover:shadow-md hover:bg-gray-50 flex items-center justify-center text-gray-700"
:title="editorOpen ? 'Startseiteneditor schließen' : 'Startseiteneditor öffnen'"
@click="editorOpen ? closeEditor() : openEditor()"
>
<X
v-if="editorOpen"
:size="15"
/>
<SlidersHorizontal
v-else
:size="15"
/>
</button>
</div>
<div
v-if="editorOpen"
class="fixed right-4 bottom-28 z-[60] w-[min(92vw,30rem)] bg-white border border-gray-200 rounded-xl shadow-xl p-4"
>
<div class="flex items-center justify-between gap-3 mb-3">
<h2 class="text-base font-semibold text-gray-900">
Startseiteneditor
</h2>
</div>
<p class="text-xs text-gray-500 mb-3">
{{ isLoggedIn ? 'Einstellungen werden serverseitig für deinen Nutzer gespeichert.' : 'Einstellungen werden nur im Browser-Cookie gespeichert.' }}
</p>
<div
v-if="editorSections.length === 0"
class="text-sm text-gray-600"
>
Keine Elemente zur Konfiguration gefunden.
</div>
<div
v-else
class="space-y-2 max-h-[50vh] overflow-auto pr-1"
>
<div
v-for="(section, index) in editorSections"
:key="section.key"
class="p-3 border border-gray-200 rounded-lg bg-gray-50"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate">
{{ getSectionLabel(section) }}
</div>
<div class="text-xs text-gray-500 truncate">
{{ section.id }}
</div>
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="px-2 py-1 text-xs rounded border border-gray-300 bg-white disabled:opacity-50"
:disabled="index === 0 || isSavingSettings"
@click="moveEditorSectionUp(index)"
>
Hoch
</button>
<button
type="button"
class="px-2 py-1 text-xs rounded border border-gray-300 bg-white disabled:opacity-50"
:disabled="index === editorSections.length - 1 || isSavingSettings"
@click="moveEditorSectionDown(index)"
>
Runter
</button>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input
v-model="section.enabled"
type="checkbox"
:disabled="isSavingSettings"
>
Anzeigen
</label>
</div>
<div
v-if="section.id === 'spielplan_team'"
class="mt-3 grid grid-cols-1 gap-2"
>
<div>
<label class="text-xs text-gray-500">Saison</label>
<select
:value="section.config?.season || ''"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
:disabled="isSavingSettings || widgetOptionsLoading"
@change="onWidgetSeasonChanged(section, $event.target.value)"
>
<option
v-for="season in spielplanSeasons"
:key="season.slug"
:value="season.slug"
>
{{ season.label }}
</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">Mannschaft</label>
<select
:value="teamKeyFromConfig(section.config)"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
:disabled="isSavingSettings || widgetOptionsLoading"
@change="onWidgetTeamChanged(section, $event.target.value)"
>
<option
v-for="team in getTeamsForSeason(section.config?.season)"
:key="team.key"
:value="team.key"
>
{{ team.label }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200">
<p class="text-sm font-semibold text-gray-900 mb-2">
Widget hinzufügen
</p>
<p class="text-xs text-gray-500 mb-3">
Spielplan-Widget für eine konkrete Mannschaft und Saison.
</p>
<div class="grid grid-cols-1 gap-2">
<div>
<label class="text-xs text-gray-500">Saison</label>
<select
v-model="newWidgetSeason"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
:disabled="widgetOptionsLoading"
@change="onNewWidgetSeasonChanged"
>
<option
v-for="season in spielplanSeasons"
:key="season.slug"
:value="season.slug"
>
{{ season.label }}
</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">Mannschaft</label>
<select
v-model="newWidgetTeamKey"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
:disabled="widgetOptionsLoading"
>
<option
v-for="team in newWidgetTeams"
:key="team.key"
:value="team.key"
>
{{ team.label }}
</option>
</select>
</div>
</div>
<button
type="button"
class="mt-3 px-3 py-2 text-sm rounded-lg border border-primary-300 text-primary-700 hover:bg-primary-50 disabled:opacity-50"
:disabled="!canAddSpielplanWidget || isSavingSettings"
@click="addSpielplanWidget"
>
Spielplan-Widget hinzufügen
</button>
</div>
<div class="mt-4 flex items-center gap-3">
<button
type="button"
class="px-3 py-2 text-sm rounded-lg bg-primary-600 hover:bg-primary-700 text-white disabled:opacity-50"
:disabled="isSavingSettings || editorSections.length === 0"
@click="saveEditor"
>
{{ isSavingSettings ? 'Speichert...' : 'Speichern' }}
</button>
<p
v-if="editorMessage"
class="text-sm"
:class="editorMessageType === 'error' ? 'text-red-700' : 'text-green-700'"
>
{{ editorMessage }}
</p>
</div>
</div>
<template
v-for="section in enabledSections"
:key="section.id"
/>
:key="section.key"
>
<HomeSpielplanTeamWidget
v-if="section.id === 'spielplan_team'"
:season="section.config?.season"
:team-name="section.config?.teamName"
:team-age-group="section.config?.teamAgeGroup"
/>
<component
:is="getComponentForSection(section.id)"
v-else
/>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { SlidersHorizontal, X } from 'lucide-vue-next'
import Hero from '~/components/Hero.vue'
import HomeTermine from '~/components/HomeTermine.vue'
import Spielplan from '~/components/Spielplan.vue'
import PublicNews from '~/components/PublicNews.vue'
import HomeActions from '~/components/HomeActions.vue'
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.vue'
const { data: config } = await useFetch('/api/config')
const { data: authStatus } = await useFetch('/api/auth/status')
const { data: homepageSettings, refresh: refreshHomepageSettings } = await useFetch('/api/homepage/settings')
// Standard-Reihenfolge, falls Config nicht vorhanden
const defaultSections = [
const editorOpen = ref(false)
const editorSections = ref([])
const isSavingSettings = ref(false)
const editorMessage = ref('')
const editorMessageType = ref('success')
const widgetOptionsLoading = ref(false)
const spielplanSeasons = ref([])
const teamOptionsBySeason = ref({})
const newWidgetSeason = ref('')
const newWidgetTeamKey = ref('')
const baseSectionDefinitions = [
{ id: 'banner', enabled: true },
{ id: 'termine', enabled: true },
{ id: 'spiele', enabled: true },
{ id: 'aktuelles', enabled: true },
{ id: 'kontakt', enabled: true }
{ id: 'kontakt', enabled: true },
{ id: 'training', enabled: false },
{ id: 'links', enabled: false },
{ id: 'vereinsmeisterschaften', enabled: false }
]
const baseSectionIds = new Set(baseSectionDefinitions.map(section => section.id))
// Lade Sections aus Config oder verwende Standard
const sections = computed(() => {
if (config.value?.homepage?.sections && Array.isArray(config.value.homepage.sections)) {
return config.value.homepage.sections
function createEntryKey(id) {
return `${id}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
function normalizeConfig(config) {
if (!config || typeof config !== 'object') return undefined
const normalized = {
season: config.season ? String(config.season) : '',
teamName: config.teamName ? String(config.teamName) : '',
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : ''
}
return defaultSections
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
return undefined
}
return normalized
}
function normalizeEntry(entry, index, fallbackId = '') {
const id = String(entry?.id || fallbackId || '').trim()
if (!id) return null
return {
key: entry?.key ? String(entry.key) : `${id}-${index}`,
id,
enabled: entry?.enabled !== false,
config: normalizeConfig(entry?.config)
}
}
function normalizeSectionList(rawSections) {
const incoming = Array.isArray(rawSections) ? rawSections : []
const sanitized = incoming
.map((section, index) => normalizeEntry(section, index))
.filter(Boolean)
if (sanitized.length === 0) {
return baseSectionDefinitions.map((section, index) => normalizeEntry(
{ ...section, key: `base-${section.id}` },
index,
section.id
))
}
const knownIds = new Set(sanitized.map(section => section.id))
const merged = [...sanitized]
for (const defaultSection of baseSectionDefinitions) {
if (!knownIds.has(defaultSection.id)) {
merged.push(normalizeEntry(
{ ...defaultSection, key: `base-${defaultSection.id}` },
merged.length,
defaultSection.id
))
}
}
return merged
}
const sections = computed(() => normalizeSectionList(config.value?.homepage?.sections))
const personalizedSections = computed(() => {
const raw = homepageSettings.value?.sections
const list = Array.isArray(raw) ? raw : []
return list.map((section, index) => normalizeEntry(section, index)).filter(Boolean)
})
// Filtere nur aktivierte Sections
const enabledSections = computed(() => {
return sections.value.filter(section => section.enabled !== false)
})
const isLoggedIn = computed(() => authStatus.value?.isLoggedIn === true)
const canCustomizeHome = computed(() => sections.value.length > 0)
function applyPersonalization(baseSections, settingsSections) {
if (!settingsSections.length) return baseSections
const presentBaseIds = new Set(
settingsSections.filter(section => baseSectionIds.has(section.id)).map(section => section.id)
)
const missingBaseSections = baseSections.filter(section => !presentBaseIds.has(section.id))
return [...settingsSections, ...missingBaseSections]
}
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
// Mapping von Section-ID zu Komponente
const componentMap = {
banner: Hero,
termine: HomeTermine,
spiele: Spielplan,
aktuelles: PublicNews,
kontakt: HomeActions
kontakt: HomeActions,
training: HomeTrainingTeaser,
links: HomeLinksTeaser,
vereinsmeisterschaften: HomeVereinsmeisterschaftenTeaser
}
function getComponentForSection(sectionId) {
return componentMap[sectionId] || null
}
function getSectionLabel(section) {
if (section.id === 'spielplan_team') {
if (!section.config?.teamName) return 'Spielplan-Widget'
return `Spielplan: ${section.config.teamName}`
}
const labels = {
banner: 'Banner (Willkommen)',
termine: 'Kommende Termine',
spiele: 'Nächste Spiele',
aktuelles: 'Aktuelles',
kontakt: 'Kontakt-Boxen',
training: 'Training-Teaser',
links: 'Links-Teaser',
vereinsmeisterschaften: 'Vereinsmeisterschaften-Teaser'
}
return labels[section.id] || section.id
}
function getTeamsForSeason(seasonSlug) {
if (!seasonSlug) return []
return teamOptionsBySeason.value[seasonSlug] || []
}
function teamKeyFromConfig(config) {
if (!config?.teamName) return ''
return `${config.teamName}||${config.teamAgeGroup || ''}`
}
function applyTeamToSectionConfig(section, teamKey) {
const season = section.config?.season || ''
const teams = getTeamsForSeason(season)
const team = teams.find(item => item.key === teamKey)
if (!team) return
section.config = {
...section.config,
season,
teamName: team.teamName,
teamAgeGroup: team.teamAgeGroup || ''
}
}
async function ensureTeamOptions(seasonSlug) {
if (!seasonSlug || teamOptionsBySeason.value[seasonSlug]) return
const result = await $fetch('/api/homepage/spielplan-options', {
query: { season: seasonSlug }
})
teamOptionsBySeason.value = {
...teamOptionsBySeason.value,
[seasonSlug]: result?.teams || []
}
if (!spielplanSeasons.value.length && Array.isArray(result?.seasons)) {
spielplanSeasons.value = result.seasons
}
}
async function loadWidgetOptions() {
if (widgetOptionsLoading.value) return
widgetOptionsLoading.value = true
try {
const result = await $fetch('/api/homepage/spielplan-options')
spielplanSeasons.value = Array.isArray(result?.seasons) ? result.seasons : []
const selectedSeason = result?.selectedSeason || spielplanSeasons.value[0]?.slug || ''
if (selectedSeason) {
teamOptionsBySeason.value = {
...teamOptionsBySeason.value,
[selectedSeason]: result?.teams || []
}
}
if (!newWidgetSeason.value) {
newWidgetSeason.value = selectedSeason
}
if (newWidgetSeason.value) {
await ensureTeamOptions(newWidgetSeason.value)
const teams = getTeamsForSeason(newWidgetSeason.value)
if (teams.length && !newWidgetTeamKey.value) {
newWidgetTeamKey.value = teams[0].key
}
}
} finally {
widgetOptionsLoading.value = false
}
}
async function openEditor() {
editorMessage.value = ''
editorSections.value = resolvedSections.value.map(section => ({
key: section.key,
id: section.id,
enabled: section.enabled !== false,
config: normalizeConfig(section.config)
}))
await loadWidgetOptions()
for (const section of editorSections.value.filter(item => item.id === 'spielplan_team')) {
const fallbackSeason = section.config?.season || newWidgetSeason.value || spielplanSeasons.value[0]?.slug || ''
if (!section.config) section.config = {}
section.config.season = fallbackSeason
await ensureTeamOptions(fallbackSeason)
const currentTeamKey = teamKeyFromConfig(section.config)
const availableTeams = getTeamsForSeason(fallbackSeason)
if (availableTeams.length && !availableTeams.find(item => item.key === currentTeamKey)) {
applyTeamToSectionConfig(section, availableTeams[0].key)
}
}
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
editorMessage.value = ''
}
function moveEditorSectionUp(index) {
if (index <= 0) return
const item = editorSections.value[index]
editorSections.value.splice(index, 1)
editorSections.value.splice(index - 1, 0, item)
}
function moveEditorSectionDown(index) {
if (index >= editorSections.value.length - 1) return
const item = editorSections.value[index]
editorSections.value.splice(index, 1)
editorSections.value.splice(index + 1, 0, item)
}
async function onWidgetSeasonChanged(section, seasonSlug) {
if (!section.config) section.config = {}
section.config.season = seasonSlug
await ensureTeamOptions(seasonSlug)
const teams = getTeamsForSeason(seasonSlug)
const currentTeamKey = teamKeyFromConfig(section.config)
if (teams.length && !teams.find(item => item.key === currentTeamKey)) {
applyTeamToSectionConfig(section, teams[0].key)
}
}
function onWidgetTeamChanged(section, teamKey) {
applyTeamToSectionConfig(section, teamKey)
}
const newWidgetTeams = computed(() => getTeamsForSeason(newWidgetSeason.value))
const canAddSpielplanWidget = computed(() => !!newWidgetSeason.value && !!newWidgetTeamKey.value)
async function onNewWidgetSeasonChanged() {
await ensureTeamOptions(newWidgetSeason.value)
const teams = getTeamsForSeason(newWidgetSeason.value)
newWidgetTeamKey.value = teams[0]?.key || ''
}
function addSpielplanWidget() {
const teams = getTeamsForSeason(newWidgetSeason.value)
const selectedTeam = teams.find(team => team.key === newWidgetTeamKey.value)
if (!selectedTeam) return
editorSections.value.push({
key: createEntryKey('spielplan_team'),
id: 'spielplan_team',
enabled: true,
config: {
season: newWidgetSeason.value,
teamName: selectedTeam.teamName,
teamAgeGroup: selectedTeam.teamAgeGroup || ''
}
})
}
async function saveEditor() {
isSavingSettings.value = true
editorMessage.value = ''
try {
await $fetch('/api/homepage/settings', {
method: 'PUT',
body: {
sections: editorSections.value.map((section, index) => ({
key: section.key || `${section.id}-${index}`,
id: section.id,
enabled: section.enabled !== false,
config: normalizeConfig(section.config)
}))
}
})
await refreshHomepageSettings()
editorMessageType.value = 'success'
editorMessage.value = 'Startseiten-Einstellungen gespeichert.'
} catch (error) {
editorMessageType.value = 'error'
editorMessage.value = error?.data?.message || 'Speichern fehlgeschlagen.'
} finally {
isSavingSettings.value = false
}
}
</script>

View File

@@ -0,0 +1,59 @@
import { getUserFromToken } from '../../utils/auth.js'
function normalizeConfig(config) {
if (!config || typeof config !== 'object') return undefined
const normalized = {
season: config.season ? String(config.season) : undefined,
teamName: config.teamName ? String(config.teamName) : undefined,
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : undefined
}
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
return undefined
}
return normalized
}
function parseSections(value) {
if (!value || typeof value !== 'string') return []
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed
.filter(section => section?.id)
.map((section, index) => ({
key: section.key ? String(section.key) : `${String(section.id)}-${index}`,
id: String(section.id),
enabled: section.enabled !== false,
config: normalizeConfig(section.config)
}))
} catch {
return []
}
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const user = token ? await getUserFromToken(token) : null
const rawCookieSections = getCookie(event, 'homepage_sections')
const cookieSections = parseSections(rawCookieSections)
const userSections = Array.isArray(user?.homepageSettings?.sections)
? user.homepageSettings.sections
.filter(section => section?.id)
.map((section, index) => ({
key: section.key ? String(section.key) : `${String(section.id)}-${index}`,
id: String(section.id),
enabled: section.enabled !== false,
config: normalizeConfig(section.config)
}))
: []
const isLoggedIn = !!user
return {
isLoggedIn,
storage: isLoggedIn ? 'user' : 'cookie',
sections: isLoggedIn ? userSections : cookieSections
}
})

View File

@@ -0,0 +1,81 @@
import { getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
function normalizeConfig(config) {
if (!config || typeof config !== 'object') return undefined
const normalized = {
season: config.season ? String(config.season) : undefined,
teamName: config.teamName ? String(config.teamName) : undefined,
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : undefined
}
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
return undefined
}
return normalized
}
function normalizeSections(sections) {
if (!Array.isArray(sections)) return []
const seenKeys = new Set()
return sections
.filter(section => section?.id)
.map((section, index) => ({
key: section.key ? String(section.key) : `${String(section.id)}-${index}`,
id: String(section.id),
enabled: section.enabled !== false,
config: normalizeConfig(section.config)
}))
.filter(section => {
if (seenKeys.has(section.key)) return false
seenKeys.add(section.key)
return true
})
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const sections = normalizeSections(body?.sections)
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const authUser = token ? await getUserFromToken(token) : null
if (!authUser) {
setCookie(event, 'homepage_sections', JSON.stringify(sections), {
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: false,
maxAge: 60 * 60 * 24 * 180
})
return {
success: true,
storage: 'cookie',
sections
}
}
const users = await readUsers()
const userIndex = users.findIndex(user => user.id === authUser.id)
if (userIndex < 0) {
throw createError({
statusCode: 404,
message: 'Benutzer nicht gefunden.'
})
}
const current = users[userIndex]
users[userIndex] = {
...current,
homepageSettings: {
sections,
updatedAt: new Date().toISOString()
}
}
await writeUsers(users)
return {
success: true,
storage: 'user',
sections
}
})

View File

@@ -0,0 +1,61 @@
import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
function teamLabel(teamName, teamAgeGroup) {
const name = String(teamName || '').trim()
const age = String(teamAgeGroup || '').trim()
if (!name) return ''
const isYouth = age.toLowerCase().includes('jugend') || name.toLowerCase().includes('jugend')
return isYouth ? `(J) ${name}` : name
}
function extractHarheimerTeams(rows) {
const seen = new Set()
const teams = []
const addTeam = (teamName, teamAgeGroup) => {
const name = String(teamName || '').trim()
if (!name) return
const age = String(teamAgeGroup || '').trim()
const key = `${name}||${age}`
if (seen.has(key)) return
seen.add(key)
teams.push({
key,
label: teamLabel(name, age),
teamName: name,
teamAgeGroup: age
})
}
for (const row of rows || []) {
if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse)
}
if (String(row.GastVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse)
}
}
return teams.sort((a, b) => a.label.localeCompare(b.label, 'de'))
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
if (query.season && !validateSeasonSlug(query.season)) {
throw createError({
statusCode: 400,
message: 'Ungültiger Saison-Slug.'
})
}
const seasons = await listSpielplanSeasons()
const selectedSeason = String(query.season || seasons[0]?.slug || '')
const dataResult = await readSpielplanData(selectedSeason ? { season: selectedSeason } : {})
return {
success: true,
selectedSeason,
seasons,
teams: extractHarheimerTeams(dataResult.data)
}
})