7 Commits

Author SHA1 Message Date
f1fc5eafc3 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m57s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #47
2026-08-12 15:25:19 +02:00
42398dad21 Merge pull request 'dev' (#46) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #46
2026-07-17 14:00:12 +02:00
d563d81584 Merge pull request 'Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen' (#45) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m59s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #45
2026-07-15 11:38:20 +02:00
380d0a8332 Merge pull request 'dev' (#44) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m41s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #44
2026-07-15 11:12:16 +02:00
301bd7acbd Merge pull request 'dev' (#43) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #43
2026-06-23 23:50:21 +02:00
05e25aa3d1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #42
2026-06-16 14:09:23 +02:00
ecae88af78 Merge pull request 'dev' (#41) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #41
2026-06-10 16:49:07 +02:00
52 changed files with 363 additions and 1182 deletions

View File

@@ -13,16 +13,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
clean: true clean: true
# Der Analyse-Workflow benötigt keine Historie. Ein flacher Checkout fetch-depth: 0
# verhindert, dass der Runner das große Repository-Pack vollständig lädt.
fetch-depth: 1
# Der Web-Workflow braucht keine Android-Artefakte. Insbesondere liegt
# dort ein großer Heap-Dump im aktuellen Commit, der auch bei einem
# flachen Checkout übertragen würde und den Runner-Checkout abbricht.
sparse-checkout: |
/*
!/android-app/
sparse-checkout-cone-mode: false
- name: Ensure clean workspace - name: Ensure clean workspace
run: | run: |
@@ -121,12 +112,11 @@ jobs:
- name: OSV-Scanner (SCA) - name: OSV-Scanner (SCA)
run: | run: |
cd "$GITHUB_WORKSPACE"
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64 curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
chmod +x osv-scanner chmod +x osv-scanner
./osv-scanner --version ./osv-scanner --version
test -f "$GITHUB_WORKSPACE/package-lock.json" test -f ./package-lock.json
./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.osv-scanner.toml" ./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
deploy-production: deploy-production:
runs-on: ubuntu-latest runs-on: ubuntu-latest

4
.gitignore vendored
View File

@@ -94,10 +94,6 @@ dist
/android-app/**/build/ /android-app/**/build/
/android-app/local.properties /android-app/local.properties
/android-app/gradle-local.properties /android-app/gradle-local.properties
# Android Play Store / release artifacts are generated locally or in CI.
/android-app/**/*.aab
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
*.hprof
# Build output (but keep production data!) # Build output (but keep production data!)
.output .output

Binary file not shown.

View File

@@ -21,20 +21,9 @@ import de.harheimertc.ui.navigation.NavigationViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import android.util.Log import android.util.Log
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var authRepository: AuthRepository
@Inject
lateinit var pushTokenRepository: PushTokenRepository
private val notificationRoute = mutableStateOf<String?>(null) private val notificationRoute = mutableStateOf<String?>(null)
private val notificationPermissionLauncher = registerForActivityResult( private val notificationPermissionLauncher = registerForActivityResult(
@@ -61,23 +50,6 @@ class MainActivity : ComponentActivity() {
notificationRoute.value = extractNotificationRoute(intent) notificationRoute.value = extractNotificationRoute(intent)
} }
override fun onResume() {
super.onResume()
syncPushTokenWhenSignedIn()
}
/**
* Server-side tokens are persistent, but re-registering the current FCM
* token is cheap and idempotent. It restores a token if server data was
* ever recovered from a backup or manually repaired.
*/
private fun syncPushTokenWhenSignedIn() {
if (authRepository.getToken().isNullOrBlank() && authRepository.getRefreshToken().isNullOrBlank()) return
lifecycleScope.launch {
pushTokenRepository.registerCurrentDevice()
}
}
private fun extractNotificationRoute(intent: Intent?): String? = private fun extractNotificationRoute(intent: Intent?): String? =
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() } intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }

View File

@@ -269,7 +269,6 @@ data class NotificationSettingsDto(
val birthdays: Boolean = false, val birthdays: Boolean = false,
val newContactRequest: Boolean = false, val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false, val newUserRegistration: Boolean = false,
val ownTtrChanges: Boolean = false,
val selectedTeamSlugs: List<String> = emptyList(), val selectedTeamSlugs: List<String> = emptyList(),
val selectedTeamSeason: String? = null, val selectedTeamSeason: String? = null,
val notificationTime: String = "09:00", val notificationTime: String = "09:00",
@@ -283,7 +282,6 @@ data class PushTokenRequest(
val token: String, val token: String,
val platform: String = "android", val platform: String = "android",
val appVersion: String? = null, val appVersion: String? = null,
val installationId: String? = null,
) )
data class BirthdayDto( data class BirthdayDto(
val name: String = "", val name: String = "",
@@ -304,7 +302,6 @@ data class QttrRowDto(
val playerName: String = "", val playerName: String = "",
val clubName: String = "", val clubName: String = "",
val currentQttr: Int? = null, val currentQttr: Int? = null,
val currentTtr: Int? = null,
val previousQttr: Int? = null, val previousQttr: Int? = null,
val birthdate: String? = null, val birthdate: String? = null,
) )

View File

@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
super.onNewToken(token) super.onNewToken(token)
serviceScope.launch { serviceScope.launch {
pushTokenRepository.registerCurrentDevice() pushTokenRepository.registerToken(token)
} }
} }

View File

@@ -81,7 +81,6 @@ object HarheimerNotifications {
"news", "news_expiring" -> Destinations.MemberNews.route "news", "news_expiring" -> Destinations.MemberNews.route
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route "event", "events_today", "events_tomorrow" -> Destinations.Termine.route
"team_matches" -> Destinations.Spielplan.route "team_matches" -> Destinations.Spielplan.route
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
"birthdays" -> Destinations.MemberArea.route "birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route "contact_request" -> Destinations.CmsContactRequests.route
"user_registration" -> Destinations.CmsBenutzer.route "user_registration" -> Destinations.CmsBenutzer.route

View File

@@ -19,7 +19,6 @@ data class NotificationPreferences(
val birthdays: Boolean = false, val birthdays: Boolean = false,
val newContactRequest: Boolean = false, val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false, val newUserRegistration: Boolean = false,
val ownTtrChanges: Boolean = false,
val selectedTeamSlugs: Set<String> = emptySet(), val selectedTeamSlugs: Set<String> = emptySet(),
val selectedTeamSeason: String? = null, val selectedTeamSeason: String? = null,
val notificationTime: String = DEFAULT_NOTIFICATION_TIME, val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
@@ -44,7 +43,6 @@ class NotificationPreferencesRepository @Inject constructor(
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false), birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false), newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false), newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(), selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() }, selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME, notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
@@ -71,7 +69,6 @@ class NotificationPreferencesRepository @Inject constructor(
.putBoolean(KEY_BIRTHDAYS, settings.birthdays) .putBoolean(KEY_BIRTHDAYS, settings.birthdays)
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest) .putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration) .putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs) .putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason) .putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime) .putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
@@ -101,7 +98,6 @@ class NotificationPreferencesRepository @Inject constructor(
const val KEY_BIRTHDAYS = "birthdays" const val KEY_BIRTHDAYS = "birthdays"
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request" const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
const val KEY_NEW_USER_REGISTRATION = "new_user_registration" const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs" const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season" const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
const val KEY_NOTIFICATION_TIME = "notification_time" const val KEY_NOTIFICATION_TIME = "notification_time"
@@ -118,7 +114,6 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
birthdays = birthdays, birthdays = birthdays,
newContactRequest = newContactRequest, newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration, newUserRegistration = newUserRegistration,
ownTtrChanges = ownTtrChanges,
selectedTeamSlugs = selectedTeamSlugs.toSet(), selectedTeamSlugs = selectedTeamSlugs.toSet(),
selectedTeamSeason = selectedTeamSeason, selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime, notificationTime = notificationTime,
@@ -134,7 +129,6 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
birthdays = birthdays, birthdays = birthdays,
newContactRequest = newContactRequest, newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration, newUserRegistration = newUserRegistration,
ownTtrChanges = ownTtrChanges,
selectedTeamSlugs = selectedTeamSlugs.toList(), selectedTeamSlugs = selectedTeamSlugs.toList(),
selectedTeamSeason = selectedTeamSeason, selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime, notificationTime = notificationTime,

View File

@@ -1,7 +1,6 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.util.Log import android.util.Log
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import de.harheimertc.BuildConfig import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
@@ -16,24 +15,19 @@ class PushTokenRepository @Inject constructor(
) { ) {
suspend fun registerCurrentDevice(): Result<Unit> = runCatching { suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
val token = FirebaseMessaging.getInstance().token.await() val token = FirebaseMessaging.getInstance().token.await()
val installationId = FirebaseInstallations.getInstance().id.await() registerToken(token).getOrThrow()
registerToken(token, installationId).getOrThrow()
} }
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching { suspend fun registerToken(token: String): Result<Unit> = runCatching {
if (token.isBlank()) return@runCatching if (token.isBlank()) return@runCatching
retryOnNetworkFailure { retryOnNetworkFailure {
val response = api.registerPushToken( val response = api.registerPushToken(
PushTokenRequest( PushTokenRequest(
token = token, token = token,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}", appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
installationId = installationId,
), ),
) )
if (!response.isSuccessful) { if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
val detail = response.errorBody()?.string().orEmpty().replace(Regex("\\s+"), " ").take(180)
error("Push-Token konnte nicht registriert werden (HTTP ${response.code()})${if (detail.isBlank()) "" else ": $detail"}")
}
} }
}.onFailure { error -> }.onFailure { error ->
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error) Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)

View File

@@ -472,7 +472,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.INTERN -> buildList { MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("TTR / QTTR", Destinations.Qttr.route)) add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route)) add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))

View File

@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.PasskeyRepository import de.harheimertc.repositories.PasskeyRepository
import de.harheimertc.repositories.PushTokenRepository
import de.harheimertc.ui.components.isValidEmail import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -30,7 +29,6 @@ data class LoginUiState(
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val repository: LoginRepository, private val repository: LoginRepository,
private val passkeyRepository: PasskeyRepository, private val passkeyRepository: PasskeyRepository,
private val pushTokenRepository: PushTokenRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState()) private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state val state: StateFlow<LoginUiState> = _state
@@ -72,7 +70,6 @@ class LoginViewModel @Inject constructor(
_state.value = current.copy(loading = true, error = null, message = null) _state.value = current.copy(loading = true, error = null, message = null)
repository.login(current.email, current.password) repository.login(current.email, current.password)
.onSuccess { response -> .onSuccess { response ->
viewModelScope.launch { pushTokenRepository.registerCurrentDevice() }
_state.value = current.copy( _state.value = current.copy(
password = "", password = "",
loading = false, loading = false,
@@ -95,7 +92,6 @@ class LoginViewModel @Inject constructor(
_state.value = current.copy(loading = true, error = null, message = null) _state.value = current.copy(loading = true, error = null, message = null)
passkeyRepository.login(context, current.email) passkeyRepository.login(context, current.email)
.onSuccess { response -> .onSuccess { response ->
viewModelScope.launch { pushTokenRepository.registerCurrentDevice() }
_state.value = current.copy( _state.value = current.copy(
password = "", password = "",
loading = false, loading = false,

View File

@@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import android.util.Log import android.util.Log
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -247,7 +248,18 @@ fun QttrScreen(
viewModel: QttrViewModel = hiltViewModel(), viewModel: QttrViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
MemberAreaPage(navController, showBackNavigation, "TTR- und QTTR-Werte", "Aktuelle Werte der Vereinsmitglieder aus myTischtennis.") { val uriHandler = LocalUriHandler.current
val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021"
MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") {
item {
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900)
TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") }
}
}
}
item { item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -257,9 +269,9 @@ fun QttrScreen(
} }
} }
when { when {
state.loading -> item { LoadingState("TTR- und QTTR-Werte werden geladen...") } state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.rows.isEmpty() -> item { Text("Keine TTR- oder QTTR-Werte gefunden.", color = Accent700) } state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) } else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
} }
} }
@@ -376,10 +388,7 @@ private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
) )
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f)) Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
} }
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(3.dp)) { Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("TTR ${row.currentTtr?.toString() ?: "-"}", color = Accent700, style = MaterialTheme.typography.labelLarge)
Text("QTTR ${row.currentQttr?.toString() ?: "-"}", color = Primary600, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
} }
} }
} }

View File

@@ -121,9 +121,9 @@ private fun MemberAreaCardGrid(navController: NavController) {
onClick = { navController.navigate(Destinations.MemberNews.route) }, onClick = { navController.navigate(Destinations.MemberNews.route) },
) )
MemberAreaCard( MemberAreaCard(
title = "TTR / QTTR", title = "QTTR",
description = "Aktuelle TTR- und QTTR-Werte der Vereinsmitglieder", description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
marker = "T", marker = "Q",
onClick = { navController.navigate(Destinations.Qttr.route) }, onClick = { navController.navigate(Destinations.Qttr.route) },
) )
} }

View File

@@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.notifications.HarheimerNotifications import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.NotificationPreferences import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.LoadingState
@@ -104,23 +103,6 @@ fun NotificationSettingsScreen(
} }
} }
item {
NotificationCard("Geräte-Registrierung") {
Text("Der Push-Token wird dem aktuell angemeldeten Konto auf diesem Server zugeordnet:", color = Accent700)
Text(BuildConfig.API_BASE_URL, color = Accent900, style = MaterialTheme.typography.bodySmall)
Button(
onClick = viewModel::registerCurrentDevice,
enabled = !state.deviceRegistrationInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.deviceRegistrationInProgress) "Gerät wird registriert..." else "Android-Gerät jetzt registrieren")
}
state.deviceRegistrationMessage?.let { message ->
Text(message, color = if (state.deviceRegistrationError) MaterialTheme.colorScheme.error else Color(0xFF166534))
}
}
}
if (state.loading) { if (state.loading) {
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") } item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
} else { } else {
@@ -180,9 +162,6 @@ fun NotificationSettingsScreen(
ToggleRow("Geburtstage", state.settings.birthdays) { ToggleRow("Geburtstage", state.settings.birthdays) {
viewModel.update(state.settings.copy(birthdays = it)) viewModel.update(state.settings.copy(birthdays = it))
} }
ToggleRow("Änderung meines TTR-Werts", state.settings.ownTtrChanges) {
viewModel.update(state.settings.copy(ownTtrChanges = it))
}
} }
} }

View File

@@ -8,7 +8,6 @@ import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.NotificationPreferences import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.repositories.NotificationPreferencesRepository import de.harheimertc.repositories.NotificationPreferencesRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,9 +22,6 @@ data class NotificationSettingsUiState(
val seasons: List<String> = emptyList(), val seasons: List<String> = emptyList(),
val error: String? = null, val error: String? = null,
val saveError: String? = null, val saveError: String? = null,
val deviceRegistrationMessage: String? = null,
val deviceRegistrationError: Boolean = false,
val deviceRegistrationInProgress: Boolean = false,
) )
@HiltViewModel @HiltViewModel
@@ -33,7 +29,6 @@ class NotificationSettingsViewModel @Inject constructor(
private val preferencesRepository: NotificationPreferencesRepository, private val preferencesRepository: NotificationPreferencesRepository,
private val mannschaftenRepository: MannschaftenRepository, private val mannschaftenRepository: MannschaftenRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val pushTokenRepository: PushTokenRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NotificationSettingsUiState()) private val _state = MutableStateFlow(NotificationSettingsUiState())
val state: StateFlow<NotificationSettingsUiState> = _state val state: StateFlow<NotificationSettingsUiState> = _state
@@ -87,30 +82,6 @@ class NotificationSettingsViewModel @Inject constructor(
update(current.copy(selectedTeamSlugs = nextTeams)) update(current.copy(selectedTeamSlugs = nextTeams))
} }
fun registerCurrentDevice() {
viewModelScope.launch {
_state.value = _state.value.copy(
deviceRegistrationInProgress = true,
deviceRegistrationMessage = null,
deviceRegistrationError = false,
)
pushTokenRepository.registerCurrentDevice()
.onSuccess {
_state.value = _state.value.copy(
deviceRegistrationInProgress = false,
deviceRegistrationMessage = "Dieses Android-Gerät wurde beim Server registriert.",
)
}
.onFailure { error ->
_state.value = _state.value.copy(
deviceRegistrationInProgress = false,
deviceRegistrationError = true,
deviceRegistrationMessage = error.message ?: "Android-Gerät konnte nicht registriert werden.",
)
}
}
}
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, currentUserName: String, syncRemote: Boolean = false) { private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, currentUserName: String, syncRemote: Boolean = false) {
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason) mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
.onSuccess { teams -> .onSuccess { teams ->

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=36 ANDROID_VERSION_CODE=32
ANDROID_VERSION_NAME=0.10.0 ANDROID_VERSION_NAME=0.9.27
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

BIN
android-app/java_pid672503.hprof Executable file

Binary file not shown.

View File

@@ -1,39 +1,39 @@
<template> <template>
<footer class="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200/90 bg-white/95 shadow-[0_-5px_18px_rgba(24,24,27,0.07)] backdrop-blur-sm"> <footer class="fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-2.5 sm:py-3"> <div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-2 sm:py-3">
<div class="flex flex-col sm:flex-row justify-between items-center gap-y-1 sm:gap-y-0"> <div class="flex flex-col sm:flex-row justify-between items-center gap-y-1 sm:gap-y-0">
<p class="text-xs sm:text-sm text-gray-600 whitespace-nowrap"> <p class="text-xs sm:text-sm text-gray-400 whitespace-nowrap">
© {{ currentYear }} Harheimer TC 1954 e.V. © {{ currentYear }} Harheimer TC 1954 e.V.
</p> </p>
<div class="flex flex-wrap justify-center items-center gap-x-4 gap-y-1 text-xs sm:text-sm relative"> <div class="flex flex-wrap justify-center items-center gap-x-4 gap-y-1 text-xs sm:text-sm relative">
<span <span
v-if="isLoggedIn && appVersion" v-if="isLoggedIn && appVersion"
class="text-xs text-gray-500" class="text-xs text-gray-600"
title="Version" title="Version"
> >
v{{ appVersion }} v{{ appVersion }}
</span> </span>
<NuxtLink <NuxtLink
to="/impressum" to="/impressum"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Impressum Impressum
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/datenschutz" to="/datenschutz"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Datenschutz Datenschutz
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/konto-loeschen" to="/konto-loeschen"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Konto loeschen Konto loeschen
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/kontakt" to="/kontakt"
class="text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
> >
Kontakt Kontakt
</NuxtLink> </NuxtLink>
@@ -41,7 +41,7 @@
<!-- Login/Logout --> <!-- Login/Logout -->
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<button <button
class="flex items-center space-x-1 rounded-sm text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 transition-colors" class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
@click="handleLogout" @click="handleLogout"
> >
<User :size="16" /> <User :size="16" />
@@ -53,7 +53,7 @@
class="relative" class="relative"
> >
<button <button
class="flex items-center space-x-1 rounded-sm text-gray-700 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 transition-colors" class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
@click="toggleMemberMenu" @click="toggleMemberMenu"
> >
<User :size="16" /> <User :size="16" />
@@ -75,25 +75,25 @@
> >
<div <div
v-if="isMemberMenuOpen" v-if="isMemberMenuOpen"
class="absolute bottom-full right-0 mb-2 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-[0_12px_28px_rgba(24,24,27,0.14)] ring-1 ring-black/5" class="absolute bottom-full right-0 mb-2 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden"
> >
<NuxtLink <NuxtLink
to="/login" to="/login"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 hover:text-primary-800 focus-visible:outline-none focus-visible:bg-primary-50 focus-visible:text-primary-800 transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false" @click="isMemberMenuOpen = false"
> >
Anmelden Anmelden
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/registrieren" to="/registrieren"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 hover:text-primary-800 focus-visible:outline-none focus-visible:bg-primary-50 focus-visible:text-primary-800 transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false" @click="isMemberMenuOpen = false"
> >
Registrieren Registrieren
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/passwort-vergessen" to="/passwort-vergessen"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 hover:text-primary-800 focus-visible:outline-none focus-visible:bg-primary-50 focus-visible:text-primary-800 transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false" @click="isMemberMenuOpen = false"
> >
Passwort vergessen Passwort vergessen

View File

@@ -58,9 +58,7 @@
</p> </p>
</div> </div>
<p class="text-sm text-gray-800"> <p class="text-sm text-gray-800">
{{ formatMatchTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse, game.Altersklasse) }} {{ game.HeimMannschaft }} vs {{ game.GastMannschaft }}
vs
{{ formatMatchTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse, game.Altersklasse) }}
</p> </p>
</div> </div>
</div> </div>
@@ -71,7 +69,6 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { formatTeamDisplayName } from '~/utils/team-display'
const props = defineProps({ const props = defineProps({
season: { season: {
@@ -94,13 +91,10 @@ const games = ref([])
const widgetTitle = computed(() => { const widgetTitle = computed(() => {
if (!props.teamName) return 'Mannschaft' if (!props.teamName) return 'Mannschaft'
return formatTeamDisplayName(props.teamName, props.teamAgeGroup) const youth = String(props.teamAgeGroup || '').toLowerCase().includes('jugend')
return youth ? `(J) ${props.teamName}` : props.teamName
}) })
function formatMatchTeamName(teamName, teamAgeGroup, competitionAgeGroup) {
return formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup) || '-'
}
const seasonLabel = computed(() => { const seasonLabel = computed(() => {
const match = String(props.season || '').match(/^(\d{2})--(\d{2})$/) const match = String(props.season || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return props.season || '-' if (!match) return props.season || '-'
@@ -190,4 +184,4 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
</script> </script>

View File

@@ -5,11 +5,11 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
<div class="flex flex-col justify-between h-full py-2"> <div class="flex flex-col justify-between h-full py-2">
<!-- Hauptmenü --> <!-- Hauptmenü -->
<div class="flex min-w-0 items-center gap-3"> <div class="flex justify-between items-center">
<!-- Logo --> <!-- Logo -->
<NuxtLink <NuxtLink
to="/" to="/"
class="flex shrink-0 items-center space-x-3 origin-left hover:scale-[1.02] transition-transform" class="flex items-center space-x-3 hover:scale-105 transition-transform"
> >
<img <img
src="~/assets/images/logos/Harheimer TC.svg" src="~/assets/images/logos/Harheimer TC.svg"
@@ -23,12 +23,9 @@
</div> </div>
</NuxtLink> </NuxtLink>
<div class="flex min-w-0 flex-1 flex-col"> <div style="display:flex;flex-direction:column;">
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<div <div class="hidden lg:flex items-center space-x-1">
class="hidden min-w-0 max-w-full lg:flex lg:items-center lg:gap-1 lg:overflow-x-auto lg:whitespace-nowrap [scrollbar-color:theme(colors.primary.700)_transparent] [scrollbar-width:thin]"
aria-label="Hauptmenü horizontal scrollbar"
>
<NuxtLink <NuxtLink
to="/" to="/"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50" class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
@@ -114,10 +111,10 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="hidden min-w-0 flex-1 lg:flex items-center h-6 border-t border-primary-700/20"> <div class="hidden lg:flex items-center h-6 border-t border-primary-700/20">
<div <div
v-if="currentSubmenu" v-if="currentSubmenu"
:class="currentSubmenu === 'mannschaften' ? 'relative min-w-0 flex-1' : currentSubmenu === 'intern' ? 'flex min-w-0 items-center gap-1' : 'flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap'" class="flex items-center space-x-1"
> >
<!-- Newsletter Submenu --> <!-- Newsletter Submenu -->
<template v-if="currentSubmenu === 'newsletter'"> <template v-if="currentSubmenu === 'newsletter'">
@@ -191,69 +188,41 @@
<!-- Mannschaften Submenu --> <!-- Mannschaften Submenu -->
<template v-if="currentSubmenu === 'mannschaften'"> <template v-if="currentSubmenu === 'mannschaften'">
<div <NuxtLink
ref="mannschaftenSubmenuRail" to="/mannschaften"
class="flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap scroll-smooth px-7 [scrollbar-color:theme(colors.primary.700)_transparent] [scrollbar-width:thin]" class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
tabindex="0" active-class="bg-primary-600"
aria-label="Mannschaften-Untermenü horizontal scrollbar" >
@scroll.passive="updateMannschaftenSubmenuControls" Übersicht
</NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<template
v-for="mannschaft in mannschaften"
:key="mannschaft.slug"
> >
<NuxtLink <NuxtLink
to="/mannschaften" :to="`/mannschaften/${mannschaft.slug}`"
class="shrink-0 px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="bg-primary-600"
>
Übersicht
</NuxtLink>
<div class="h-3 w-px shrink-0 bg-primary-700" />
<template
v-for="mannschaft in mannschaften"
:key="mannschaft.slug"
>
<NuxtLink
:to="`/mannschaften/${mannschaft.slug}`"
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
{{ mannschaft.mannschaft }}
</NuxtLink>
</template>
<div class="h-3 w-px shrink-0 bg-primary-700" />
<NuxtLink
to="/mannschaften/spielplaene"
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600" active-class="text-white bg-primary-600"
> >
Spielpläne {{ mannschaft.mannschaft }}
</NuxtLink> </NuxtLink>
<NuxtLink </template>
to="/spielsysteme" <div class="h-3 w-px bg-primary-700" />
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all" <NuxtLink
active-class="text-white bg-primary-600" to="/mannschaften/spielplaene"
> class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
Spielsysteme active-class="text-white bg-primary-600"
</NuxtLink>
</div>
<button
v-if="showMannschaftenSubmenuLeftControl"
type="button"
class="absolute inset-y-0 left-0 z-10 flex w-9 items-center justify-start bg-gradient-to-r from-primary-900 via-primary-900/90 to-transparent pl-1 text-primary-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300"
aria-label="Vorherige Mannschaften anzeigen"
@pointerdown.stop
@click.stop.prevent="scrollMannschaftenSubmenuLeft"
> >
Spielpläne
</button> </NuxtLink>
<button <NuxtLink
v-if="showMannschaftenSubmenuRightControl" to="/spielsysteme"
type="button" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
class="absolute inset-y-0 right-0 z-10 flex w-9 items-center justify-end bg-gradient-to-l from-primary-900 via-primary-900/90 to-transparent pr-1 text-primary-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300" active-class="text-white bg-primary-600"
aria-label="Weitere Mannschaften anzeigen"
@pointerdown.stop
@click.stop.prevent="scrollMannschaftenSubmenuRight"
> >
Spielsysteme
</button> </NuxtLink>
</template> </template>
<!-- Training Submenu --> <!-- Training Submenu -->
@@ -290,8 +259,7 @@
<!-- Intern Submenu --> <!-- Intern Submenu -->
<template v-if="currentSubmenu === 'intern'"> <template v-if="currentSubmenu === 'intern'">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap"> <NuxtLink
<NuxtLink
to="/mitgliederbereich" to="/mitgliederbereich"
class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
active-class="bg-primary-600" active-class="bg-primary-600"
@@ -311,7 +279,7 @@
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600" active-class="text-white bg-primary-600"
> >
TTR / QTTR QTTR
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/mitgliederbereich/news" to="/mitgliederbereich/news"
@@ -355,10 +323,9 @@
Kontaktanfragen Kontaktanfragen
</NuxtLink> </NuxtLink>
</template> </template>
</div>
<template v-if="isAdmin"> <template v-if="isAdmin">
<div class="h-3 w-px shrink-0 bg-primary-700" /> <div class="h-3 w-px bg-primary-700" />
<div class="relative shrink-0 inline-block"> <div class="relative inline-block">
<button <button
class="px-2.5 py-1 text-xs text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded transition-all flex items-center" class="px-2.5 py-1 text-xs text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded transition-all flex items-center"
:class="route.path.startsWith('/cms') ? 'text-white bg-primary-600' : ''" :class="route.path.startsWith('/cms') ? 'text-white bg-primary-600' : ''"
@@ -443,13 +410,6 @@
> >
Einstellungen Einstellungen
</NuxtLink> </NuxtLink>
<NuxtLink
to="/cms/mytischtennis"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
myTischtennis
</NuxtLink>
<NuxtLink <NuxtLink
to="/cms/benutzer" to="/cms/benutzer"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors" class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@@ -766,7 +726,7 @@
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors" class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false" @click="isMobileMenuOpen = false"
> >
TTR / QTTR QTTR
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/mitgliederbereich/news" to="/mitgliederbereich/news"
@@ -867,13 +827,6 @@
> >
Einstellungen Einstellungen
</NuxtLink> </NuxtLink>
<NuxtLink
to="/cms/mytischtennis"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
myTischtennis
</NuxtLink>
<NuxtLink <NuxtLink
v-if="canManageUsers" v-if="canManageUsers"
to="/cms/benutzer" to="/cms/benutzer"
@@ -900,7 +853,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Menu, X, ChevronDown } from 'lucide-vue-next' import { Menu, X, ChevronDown } from 'lucide-vue-next'
@@ -908,9 +861,6 @@ const route = useRoute()
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const mobileSubmenu = ref(null) const mobileSubmenu = ref(null)
const mannschaften = ref([]) const mannschaften = ref([])
const mannschaftenSubmenuRail = ref(null)
const showMannschaftenSubmenuLeftControl = ref(false)
const showMannschaftenSubmenuRightControl = ref(false)
const hasGalleryImages = ref(false) const hasGalleryImages = ref(false)
const showCmsDropdown = ref(false) const showCmsDropdown = ref(false)
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -948,38 +898,6 @@ const toggleMobileSubmenu = (menu) => {
mobileSubmenu.value = mobileSubmenu.value === menu ? null : menu mobileSubmenu.value = mobileSubmenu.value === menu ? null : menu
} }
const updateMannschaftenSubmenuControls = () => {
const rail = mannschaftenSubmenuRail.value
if (!rail) return
const canScroll = rail.scrollWidth > rail.clientWidth + 1
const atStart = rail.scrollLeft <= 1
const atEnd = rail.scrollLeft + rail.clientWidth >= rail.scrollWidth - 1
showMannschaftenSubmenuLeftControl.value = canScroll && !atStart
showMannschaftenSubmenuRightControl.value = canScroll && !atEnd
}
const scrollMannschaftenSubmenuLeft = () => {
const rail = mannschaftenSubmenuRail.value
if (!rail) return
rail.scrollBy({
left: -Math.max(rail.clientWidth * 0.8, 160),
behavior: 'smooth'
})
}
const scrollMannschaftenSubmenuRight = () => {
const rail = mannschaftenSubmenuRail.value
if (!rail) return
rail.scrollBy({
left: Math.max(rail.clientWidth * 0.8, 160),
behavior: 'smooth'
})
}
const loadMannschaften = async () => { const loadMannschaften = async () => {
try { try {
const attempt = async () => { const attempt = async () => {
@@ -1064,23 +982,16 @@ onMounted(() => {
// Listen for global updates to mannschaften (e.g., CMS saved) // Listen for global updates to mannschaften (e.g., CMS saved)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('mannschaften:changed', loadMannschaften) window.addEventListener('mannschaften:changed', loadMannschaften)
window.addEventListener('resize', updateMannschaftenSubmenuControls)
} }
nextTick(updateMannschaftenSubmenuControls)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('mannschaften:changed', loadMannschaften) window.removeEventListener('mannschaften:changed', loadMannschaften)
window.removeEventListener('resize', updateMannschaftenSubmenuControls)
} }
}) })
watch([currentSubmenu, mannschaften], () => {
nextTick(updateMannschaftenSubmenuControls)
}, { flush: 'post' })
const toggleSubmenu = (menu) => { const toggleSubmenu = (menu) => {
// Wenn wir schon im richtigen Bereich sind, nichts tun (Submenu bleibt offen) // Wenn wir schon im richtigen Bereich sind, nichts tun (Submenu bleibt offen)
// Wenn nicht, zur Hauptseite navigieren // Wenn nicht, zur Hauptseite navigieren

View File

@@ -146,7 +146,7 @@
Heim Heim
</p> </p>
<p class="font-semibold text-gray-900"> <p class="font-semibold text-gray-900">
{{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse, game.Altersklasse) }} {{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse) }}
</p> </p>
</div> </div>
<div class="text-center mx-4"> <div class="text-center mx-4">
@@ -159,7 +159,7 @@
Gast Gast
</p> </p>
<p class="font-semibold text-gray-900"> <p class="font-semibold text-gray-900">
{{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse, game.Altersklasse) }} {{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse) }}
</p> </p>
</div> </div>
</div> </div>
@@ -220,7 +220,6 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { formatTeamDisplayName } from '~/utils/team-display'
const spielplanData = ref([]) const spielplanData = ref([])
const isLoading = ref(false) const isLoading = ref(false)
@@ -356,10 +355,17 @@ const formatTime = (terminString) => {
} }
} }
const formatTeamName = (teamName, ageGroup, competitionAgeGroup) => { const formatTeamName = (teamName, ageGroup) => {
if (!teamName) return 'Nicht angegeben' if (!teamName) return 'Nicht angegeben'
return formatTeamDisplayName(teamName, ageGroup, competitionAgeGroup) // Prüfe ob es Nachwuchs ist
const isNachwuchs = ageGroup && (
ageGroup.toLowerCase().includes('jugend') ||
teamName.toLowerCase().includes('jugend')
)
// Füge (J) für Nachwuchs hinzu
return isNachwuchs ? `(J) ${teamName}` : teamName
} }
const formatRunde = (runde) => { const formatRunde = (runde) => {

View File

@@ -56,17 +56,14 @@ has_tracked_files_under() {
install_dependencies() { install_dependencies() {
if [ -f "package-lock.json" ]; then if [ -f "package-lock.json" ]; then
echo " Running: npm ci --no-audit" echo " Running: npm ci"
# The registry audit endpoint is independent of package installation and if ! npm ci; then
# can be temporarily unavailable (e.g. DNS EAI_AGAIN). Do not block a
# production rollout on that external advisory lookup.
if ! npm ci --no-audit --fund=false; then
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..." echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
npm install --no-audit --fund=false npm install
fi fi
else else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..." echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install --no-audit --fund=false npm install
fi fi
} }
@@ -105,18 +102,6 @@ install_dependencies_if_needed() {
printf '%s\n' "$current_lock_hash" > "$lock_hash_file" printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
} }
install_playwright_browser() {
if [ ! -d "node_modules/playwright" ]; then
echo " Playwright ist nicht installiert; Browser-Installation wird übersprungen"
return 0
fi
# Muss mit demselben Benutzer laufen, der später den PM2-Prozess startet;
# Playwright legt den Browser im benutzerspezifischen Cache ab.
echo " Installing/verifying Playwright Chromium browser..."
npx playwright install chromium
}
use_project_node() { use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then if [ -s "$NVM_DIR/nvm.sh" ]; then
@@ -293,16 +278,21 @@ echo "3. Installing dependencies..."
use_project_node use_project_node
ensure_node_version ensure_node_version
install_dependencies_if_needed install_dependencies_if_needed
install_playwright_browser
# 4. Stop running apps before replacing build artifacts # 4. Stop running apps before replacing build artifacts
echo "" echo ""
echo "4. Stopping PM2 before replacing build artifacts..." echo "4. Stopping PM2 before replacing build artifacts..."
if command -v pm2 >/dev/null 2>&1 && pm2 describe harheimertc >/dev/null 2>&1; then if command -v pm2 >/dev/null 2>&1; then
pm2 stop harheimertc || true for instance_name in harheimertc harheimertc-3102; do
echo " ✓ harheimertc gestoppt" if pm2 describe "$instance_name" >/dev/null 2>&1; then
pm2 stop "$instance_name" || true
echo "$instance_name gestoppt"
else
echo " PM2-Prozess $instance_name läuft nicht"
fi
done
else else
echo " PM2-Prozess harheimertc läuft nicht oder PM2 ist nicht verfügbar" echo " PM2 ist nicht verfügbar"
fi fi
# 5. Remove old build (but keep data!) # 5. Remove old build (but keep data!)
@@ -569,14 +559,18 @@ restart_pm2_instance() {
fi fi
} }
# Starte/Neustarte die Produktionsinstanz # Starte/Neustarte beide Instanzen
INSTANCE_ERRORS=0 INSTANCE_ERRORS=0
if ! restart_pm2_instance "harheimertc"; then if ! restart_pm2_instance "harheimertc"; then
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1)) INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi fi
# Prüfe, ob der Prozess läuft if ! restart_pm2_instance "harheimertc-3102"; then
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi
# Prüfe, ob beide Prozesse laufen
sleep 2 sleep 2
echo "" echo ""
echo " Checking PM2 instances status..." echo " Checking PM2 instances status..."
@@ -588,11 +582,19 @@ else
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1)) INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi fi
if pm2 describe harheimertc-3102 | grep -q "online"; then
echo " ✓ PM2-Prozess 'harheimertc-3102' läuft (online)"
else
echo " WARNING: PM2-Prozess 'harheimertc-3102' ist nicht online. Prüfe Logs: pm2 logs harheimertc-3102"
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi
if [ "$INSTANCE_ERRORS" -gt 0 ]; then if [ "$INSTANCE_ERRORS" -gt 0 ]; then
echo "" echo ""
echo "WARNING: Einige PM2-Instanzen haben Probleme. Bitte manuell prüfen:" echo "WARNING: Einige PM2-Instanzen haben Probleme. Bitte manuell prüfen:"
echo " pm2 status" echo " pm2 status"
echo " pm2 logs harheimertc" echo " pm2 logs harheimertc"
echo " pm2 logs harheimertc-3102"
fi fi
echo "" echo ""
@@ -601,5 +603,8 @@ echo "The application is now running with the latest code and your production da
echo "" echo ""
echo "Useful commands:" echo "Useful commands:"
echo " pm2 logs harheimertc # View logs (Port 3100)" echo " pm2 logs harheimertc # View logs (Port 3100)"
echo " pm2 logs harheimertc-3102 # View logs (Port 3102)"
echo " pm2 status # View status" echo " pm2 status # View status"
echo " pm2 restart harheimertc # Restart instance on port 3100" echo " pm2 restart harheimertc # Restart instance on port 3100"
echo " pm2 restart harheimertc-3102 # Restart instance on port 3102"
echo " pm2 restart all # Restart all instances"

View File

@@ -69,17 +69,14 @@ has_tracked_files_under() {
install_dependencies() { install_dependencies() {
if [ -f "package-lock.json" ]; then if [ -f "package-lock.json" ]; then
echo " Running: npm ci --no-audit" echo " Running: npm ci"
# The registry audit endpoint is independent of package installation and if ! npm ci; then
# can be temporarily unavailable (e.g. DNS EAI_AGAIN). Do not block a
# test rollout on that external advisory lookup.
if ! npm ci --no-audit --fund=false; then
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..." echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
npm install --no-audit --fund=false npm install
fi fi
else else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..." echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install --no-audit --fund=false npm install
fi fi
} }
@@ -118,18 +115,6 @@ install_dependencies_if_needed() {
printf '%s\n' "$current_lock_hash" > "$lock_hash_file" printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
} }
install_playwright_browser() {
if [ ! -d "node_modules/playwright" ]; then
echo " Playwright ist nicht installiert; Browser-Installation wird übersprungen"
return 0
fi
# Muss mit demselben Benutzer laufen, der später den PM2-Prozess startet;
# Playwright legt den Browser im benutzerspezifischen Cache ab.
echo " Installing/verifying Playwright Chromium browser..."
npx playwright install chromium
}
use_project_node() { use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then if [ -s "$NVM_DIR/nvm.sh" ]; then
@@ -299,7 +284,6 @@ echo "3. Installing dependencies..."
use_project_node use_project_node
ensure_node_version ensure_node_version
install_dependencies_if_needed install_dependencies_if_needed
install_playwright_browser
# 4. Stop running app before replacing build artifacts # 4. Stop running app before replacing build artifacts
echo "" echo ""
@@ -583,10 +567,9 @@ restart_pm2_instance() {
# Starte/Neustarte Test-Instanz # Starte/Neustarte Test-Instanz
if ! restart_pm2_instance "harheimertc.test"; then if ! restart_pm2_instance "harheimertc.test"; then
echo "" echo ""
echo "ERROR: PM2-Instanz konnte nicht gestartet werden." echo "WARNING: PM2-Instanz konnte nicht gestartet werden. Bitte manuell prüfen:"
echo " pm2 status" echo " pm2 status"
echo " pm2 logs harheimertc.test" echo " pm2 logs harheimertc.test"
exit 1
fi fi
# Prüfe, ob der Prozess läuft # Prüfe, ob der Prozess läuft
@@ -597,16 +580,9 @@ echo " Checking PM2 instance status..."
if pm2 describe harheimertc.test | grep -q "online"; then if pm2 describe harheimertc.test | grep -q "online"; then
echo " ✓ PM2-Prozess 'harheimertc.test' läuft (online)" echo " ✓ PM2-Prozess 'harheimertc.test' läuft (online)"
else else
echo " ERROR: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test" echo " WARNING: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test"
exit 1
fi fi
if ! curl --fail --silent --show-error --max-time 15 http://127.0.0.1:3102/ >/dev/null; then
echo "ERROR: Test-Instanz antwortet nicht auf Port 3102."
exit 1
fi
echo " ✓ HTTP-Healthcheck auf Port 3102 erfolgreich"
echo "" echo ""
echo "=== Test-Instanz Deployment completed successfully! ===" echo "=== Test-Instanz Deployment completed successfully! ==="
echo "The test application is now running with the latest code and your test data preserved." echo "The test application is now running with the latest code and your test data preserved."

View File

@@ -10,7 +10,7 @@ git pull origin main
# Dependencies installieren # Dependencies installieren
echo "📦 Installing dependencies..." echo "📦 Installing dependencies..."
npm install --no-audit --fund=false npm install
# Website bauen (Static Generation) # Website bauen (Static Generation)
echo "🔨 Building website..." echo "🔨 Building website..."

View File

@@ -61,6 +61,22 @@ module.exports = {
out_file: '/var/log/pm2/harheimertc-out.log', out_file: '/var/log/pm2/harheimertc-out.log',
log_file: '/var/log/pm2/harheimertc-combined.log', log_file: '/var/log/pm2/harheimertc-combined.log',
time: true time: true
},
{
name: 'harheimertc-3102',
// Zweite Instanz auf Port 3102
script: 'node',
args: '.output/server/index.mjs',
cwd: '/var/www/harheimertc',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: createEnv(3102),
error_file: '/var/log/pm2/harheimertc-3102-error.log',
out_file: '/var/log/pm2/harheimertc-3102-out.log',
log_file: '/var/log/pm2/harheimertc-3102-combined.log',
time: true
} }
] ]
} }

View File

@@ -58,7 +58,7 @@ module.exports = {
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: '1G', max_memory_restart: '1G',
env: createEnv(3102), env: createEnv(process.env.PORT || 3102),
error_file: '/var/log/pm2/harheimertc.test-error.log', error_file: '/var/log/pm2/harheimertc.test-error.log',
out_file: '/var/log/pm2/harheimertc.test-out.log', out_file: '/var/log/pm2/harheimertc.test-out.log',
log_file: '/var/log/pm2/harheimertc.test-combined.log', log_file: '/var/log/pm2/harheimertc.test-combined.log',

419
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.9", "version": "1.8.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.9", "version": "1.8.4",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
@@ -21,7 +21,6 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"playwright": "^1.62.1",
"quill": "2.0.2", "quill": "2.0.2",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"vue": "^3.5.22" "vue": "^3.5.22"
@@ -877,9 +876,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -895,13 +894,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3" "@img/sharp-libvips-darwin-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -917,20 +916,20 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3" "@img/sharp-libvips-darwin-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-freebsd-wasm32": { "node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.4" "@img/sharp-wasm32": "0.35.3"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -940,9 +939,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -956,9 +955,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -972,9 +971,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -988,9 +987,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1004,9 +1003,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1020,9 +1019,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1036,9 +1035,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1052,9 +1051,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1068,9 +1067,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1084,9 +1083,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1100,9 +1099,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1118,13 +1117,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3" "@img/sharp-libvips-linux-arm": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1140,13 +1139,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3" "@img/sharp-libvips-linux-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1162,13 +1161,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3" "@img/sharp-libvips-linux-ppc64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1184,13 +1183,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3" "@img/sharp-libvips-linux-riscv64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1206,13 +1205,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3" "@img/sharp-libvips-linux-s390x": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1228,13 +1227,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3" "@img/sharp-libvips-linux-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1250,13 +1249,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3" "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1272,17 +1271,17 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3" "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.11.3" "@emnapi/runtime": "^1.11.1"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -1292,16 +1291,16 @@
} }
}, },
"node_modules/@img/sharp-webcontainers-wasm32": { "node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.4" "@img/sharp-wasm32": "0.35.3"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -1311,9 +1310,9 @@
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1330,9 +1329,9 @@
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1349,9 +1348,9 @@
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3129,8 +3128,6 @@
}, },
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3146,8 +3143,6 @@
}, },
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3157,8 +3152,6 @@
}, },
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3350,16 +3343,14 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11", "@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.10",
"chai": "^6.2.2", "chai": "^6.2.2",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -3368,13 +3359,11 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.1.11", "@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@@ -3396,8 +3385,6 @@
}, },
"node_modules/@vitest/mocker/node_modules/estree-walker": { "node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3405,9 +3392,7 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3418,13 +3403,11 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.10",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -3432,14 +3415,12 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.11", "@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.10",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -3448,9 +3429,7 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -3458,13 +3437,11 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.11", "@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -4055,8 +4032,6 @@
}, },
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4559,8 +4534,6 @@
}, },
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4938,16 +4911,16 @@
} }
}, },
"node_modules/css-select": { "node_modules/css-select": {
"version": "6.0.0", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"boolbase": "^1.0.0", "boolbase": "^1.0.0",
"css-what": "^7.0.0", "css-what": "^6.1.0",
"domhandler": "^5.0.3", "domhandler": "^5.0.2",
"domutils": "^3.2.2", "domutils": "^3.0.1",
"nth-check": "^2.1.1" "nth-check": "^2.0.1"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
@@ -4967,9 +4940,9 @@
} }
}, },
"node_modules/css-what": { "node_modules/css-what": {
"version": "7.0.0", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -7618,9 +7591,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.3.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
@@ -7858,9 +7829,7 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "9.1.1", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -8558,50 +8527,6 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/portfinder": { "node_modules/portfinder": {
"version": "1.0.38", "version": "1.0.38",
"dev": true, "dev": true,
@@ -9300,9 +9225,7 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.16.0", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -10266,9 +10189,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.35.4", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
@@ -10282,31 +10205,31 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.4", "@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.4", "@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.4" "@img/sharp-win32-x64": "0.35.3"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/node": { "@types/node": {
@@ -10807,18 +10730,18 @@
} }
}, },
"node_modules/svgo": { "node_modules/svgo": {
"version": "4.1.0", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz",
"integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^11.1.0", "commander": "^11.1.0",
"css-select": "^6.0.0", "css-select": "^5.1.0",
"css-tree": "^3.0.1", "css-tree": "^3.0.1",
"css-what": "^7.0.0", "css-what": "^6.1.0",
"csso": "^5.0.5", "csso": "^5.0.5",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"sax": "1.6.1" "sax": "^1.5.0"
}, },
"bin": { "bin": {
"svgo": "bin/svgo.js" "svgo": "bin/svgo.js"
@@ -11171,9 +11094,7 @@
} }
}, },
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.1.1", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -12062,19 +11983,17 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.1.11", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.1.11", "@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.11", "@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.11", "@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.11", "@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.11", "@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.11", "@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -12102,12 +12021,12 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11", "@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.11", "@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.11", "@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.11", "@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.11", "@vitest/ui": "4.1.10",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*", "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.9", "version": "1.8.5",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -18,7 +18,7 @@
"test": "vitest run", "test": "vitest run",
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts", "test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
"check-security": "node scripts/verify-no-public-writes.js", "check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-test.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js", "sync-public-data": "node scripts/sync-public-data.js",
"data-backups:list": "node scripts/data-backup-restore.js list", "data-backups:list": "node scripts/data-backup-restore.js list",
"data-backups:restore": "node scripts/data-backup-restore.js restore", "data-backups:restore": "node scripts/data-backup-restore.js restore",
@@ -44,7 +44,6 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"playwright": "^1.62.1",
"quill": "2.0.2", "quill": "2.0.2",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"vue": "^3.5.22" "vue": "^3.5.22"
@@ -68,7 +67,6 @@
}, },
"overrides": { "overrides": {
"@peculiar/x509": "1.13.0", "@peculiar/x509": "1.13.0",
"esbuild": "0.28.1", "esbuild": "0.28.1"
"qs": "6.16.0"
} }
} }

View File

@@ -201,20 +201,6 @@
</p> </p>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="authStore.hasAnyRole('admin', 'vorstand')"
to="/cms/mytischtennis"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-teal-100 rounded-lg flex items-center justify-center group-hover:bg-teal-600 transition-colors">
<RefreshCw :size="24" class="text-teal-600 group-hover:text-white" />
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">myTischtennis</h2>
</div>
<p class="text-gray-600">Vereinszugang und TTR-Synchronisierung</p>
</NuxtLink>
<!-- Benutzerverwaltung (Admin ODER Vorstand) --> <!-- Benutzerverwaltung (Admin ODER Vorstand) -->
<NuxtLink <NuxtLink
v-if="authStore.hasAnyRole('admin', 'vorstand')" v-if="authStore.hasAnyRole('admin', 'vorstand')"
@@ -263,7 +249,7 @@
</template> </template>
<script setup> <script setup>
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert, RefreshCw } from 'lucide-vue-next' import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert } from 'lucide-vue-next'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()

View File

@@ -1,52 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50 py-10">
<div class="max-w-2xl mx-auto px-4">
<h1 class="text-3xl font-display font-bold text-gray-900">myTischtennis-Verbindung</h1>
<p class="mt-2 text-gray-600">Vereinszugang für den geschützten Import aktueller TTR- und QTTR-Werte.</p>
<form class="mt-8 bg-white rounded-xl shadow-lg p-6 space-y-5" @submit.prevent="save">
<div class="rounded-lg bg-amber-50 border border-amber-200 p-4 text-sm text-amber-900">
Das Passwort wird verschlüsselt auf dem Server gespeichert und nie wieder angezeigt. Zugriff haben ausschließlich Admin und Vorstand.
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">myTischtennis-E-Mail</label>
<input v-model.trim="form.email" type="email" required autocomplete="username" class="w-full rounded-lg border border-gray-300 px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input v-model="form.password" type="password" required autocomplete="current-password" class="w-full rounded-lg border border-gray-300 px-3 py-2">
</div>
<div class="grid sm:grid-cols-2 gap-4">
<label class="block text-sm font-medium text-gray-700">Verband<input v-model.trim="form.association" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"></label>
<label class="block text-sm font-medium text-gray-700">Vereinsnummer<input v-model.trim="form.clubId" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"></label>
</div>
<p v-if="status.configured" class="text-sm text-gray-600">Hinterlegt: {{ status.email }} · letzter erfolgreicher Abruf: {{ formattedLastImport }}</p>
<p v-if="status.lastImportError" class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Letzter Abruf fehlgeschlagen: {{ status.lastImportError }}
</p>
<p v-if="message" class="text-sm" :class="error ? 'text-red-700' : 'text-green-700'">{{ message }}</p>
<div class="flex gap-3 flex-wrap">
<button :disabled="busy" class="rounded-lg bg-primary-600 px-4 py-2 font-semibold text-white disabled:opacity-60">Zugang speichern</button>
<button type="button" :disabled="busy || !status.configured" class="rounded-lg border border-primary-600 px-4 py-2 font-semibold text-primary-700 disabled:opacity-60" @click="runImport">Jetzt abrufen</button>
<button v-if="authStore.hasRole('admin')" type="button" :disabled="busy" class="rounded-lg border border-teal-600 px-4 py-2 font-semibold text-teal-700 disabled:opacity-60" @click="sendTestPush">Push-Test an mich senden</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '~/stores/auth'
definePageMeta({ middleware: 'auth' })
useHead({ title: 'myTischtennis-Verbindung CMS' })
const form = reactive({ email: '', password: '', association: 'HeTTV', clubId: '43030' })
const authStore = useAuthStore()
const status = ref({ configured: false })
const busy = ref(false); const message = ref(''); const error = ref(false)
const formattedLastImport = computed(() => status.value.lastSuccessfulImportAt ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.value.lastSuccessfulImportAt)) : 'noch nie')
async function load() { status.value = await $fetch('/api/cms/mytischtennis') }
async function save() { busy.value = true; message.value = ''; error.value = false; try { status.value = await $fetch('/api/cms/mytischtennis', { method: 'PUT', body: form }); form.password = ''; message.value = 'Zugang verschlüsselt gespeichert.' } catch { error.value = true; message.value = 'Zugang konnte nicht gespeichert werden.' } finally { busy.value = false } }
async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch (caught) { error.value = true; message.value = caught?.data?.message || 'Abruf fehlgeschlagen. Details stehen im Serverlog.'; await load().catch(() => {}) } finally { busy.value = false } }
async function sendTestPush() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/notifications/test', { method: 'POST' }); if (result.registeredTokenCount === 0) { message.value = 'Kein Android-Token für dieses Konto gespeichert.'; error.value = true } else if (result.skipped) { message.value = `${result.registeredTokenCount} Android-Gerät(e) registriert, aber der FCM-Push-Dienst ist auf dem Server nicht konfiguriert.`; error.value = true } else if (result.deliveryError) { message.value = `${result.registeredTokenCount} Android-Gerät(e) registriert, aber: ${result.deliveryError}`; error.value = true } else if (result.sent > 0) { message.value = `Push-Test wurde an ${result.sent} registrierte(s) Android-Gerät(e) gesendet.` } else { message.value = `${result.registeredTokenCount} Android-Gerät(e) registriert, Zustellung fehlgeschlagen. Bitte Serverlog prüfen.`; error.value = true } } catch (caught) { error.value = true; message.value = caught?.data?.statusMessage || caught?.data?.message || 'Push-Test konnte nicht gesendet werden.' } finally { busy.value = false } }
onMounted(load)
</script>

View File

@@ -362,7 +362,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { rowMatchesTeamFilter } from '~/utils/spielplan-filter.js' import { rowMatchesTeamFilter } from '../../utils/spielplan-filter.js'
const route = useRoute() const route = useRoute()

View File

@@ -360,7 +360,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { filterSpielplanRows, toApiTeamParam } from '~/utils/spielplan-filter.js' import { filterSpielplanRows, toApiTeamParam } from '../../utils/spielplan-filter.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -3,11 +3,18 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
<div> <div>
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4"> <h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
TTR- und QTTR-Werte QTTR-Werte
</h1> </h1>
<div class="w-24 h-1 bg-primary-600 mb-6" /> <div class="w-24 h-1 bg-primary-600 mb-6" />
<p class="text-lg text-gray-700 max-w-3xl"> <p class="text-lg text-gray-700 max-w-3xl">
Aktuelle TTR- und QTTR-Werte des Harheimer TC. Der Abruf erfolgt über die geschützte Vereinsverbindung. Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf
<a
:href="externalUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-800 underline"
>myTischtennis</a>
wechseln.
</p> </p>
</div> </div>
@@ -30,13 +37,13 @@
v-if="pending" v-if="pending"
class="py-12 text-center text-gray-500" class="py-12 text-center text-gray-500"
> >
Lade TTR- und QTTR-Werte... Lade QTTR-Werte...
</div> </div>
<div <div
v-else-if="error" v-else-if="error"
class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"
> >
{{ error.statusMessage || error.message || 'TTR- und QTTR-Werte konnten nicht geladen werden.' }} {{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
</div> </div>
<div <div
v-else v-else
@@ -54,9 +61,6 @@
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500"> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Verein Verein
</th> </th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
TTR
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500"> <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
QTTR QTTR
</th> </th>
@@ -79,9 +83,6 @@
<td class="px-4 py-3 text-sm text-gray-700"> <td class="px-4 py-3 text-sm text-gray-700">
{{ row.clubName || 'Harheimer TC' }} {{ row.clubName || 'Harheimer TC' }}
</td> </td>
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
{{ row.currentTtr ?? '' }}
</td>
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums"> <td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
{{ row.currentQttr ?? '' }} {{ row.currentQttr ?? '' }}
</td> </td>
@@ -98,6 +99,7 @@
import { computed } from 'vue' import { computed } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const externalUrl = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
@@ -124,7 +126,7 @@ function isMaleGender(value) {
function isFemaleGender(value) { function isFemaleGender(value) {
const gender = normalizeName(value) const gender = normalizeName(value)
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau') || gender.includes('female') return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
} }
function isOwnRow(playerName) { function isOwnRow(playerName) {
@@ -178,6 +180,6 @@ function formatDate(value) {
} }
useHead({ useHead({
title: 'TTR- und QTTR-Werte - Harheimer TC' title: 'QTTR-Werte - Harheimer TC'
}) })
</script> </script>

View File

@@ -1,10 +0,0 @@
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import { publicConnectionStatus, readMyTischtennisConnection } from '../../utils/mytischtennis-connection.js'
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
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
return publicConnectionStatus(await readMyTischtennisConnection())
})

View File

@@ -1,17 +0,0 @@
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import { readMyTischtennisConnection, saveMyTischtennisConnection, publicConnectionStatus } from '../../utils/mytischtennis-connection.js'
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
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
const body = await readBody(event)
if (!/^\S+@\S+\.\S+$/.test(String(body.email || '')) || String(body.password || '').length < 1) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail und Passwort sind erforderlich.' })
}
const previous = await readMyTischtennisConnection()
const connection = { ...previous, email: body.email.trim(), password: body.password, association: String(body.association || 'HeTTV'), clubId: String(body.clubId || '43030') }
await saveMyTischtennisConnection(connection)
return publicConnectionStatus(connection)
})

View File

@@ -1,24 +0,0 @@
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
import { importQttrValues } from '../../../utils/qttr-import.js'
import { readMyTischtennisConnection, saveMyTischtennisConnection } from '../../../utils/mytischtennis-connection.js'
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
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
const connection = await readMyTischtennisConnection()
if (!connection) throw createError({ statusCode: 409, statusMessage: 'Keine myTischtennis-Verbindung eingerichtet.' })
try {
const result = await importQttrValues({ connection })
return { success: true, rowCount: result.rowCount, importedAt: result.importedAt }
} catch (error) {
const fullDetail = String(error.stack || error.message || error)
const detail = fullDetail.slice(0, 500)
// Vollständig nur ins serverseitige PM2-Log schreiben; im CMS wird der Fehler
// bewusst gekürzt gespeichert, damit Browser-/Systempfade nicht öffentlich werden.
console.error('[mytischtennis] TTR-Abruf fehlgeschlagen:', fullDetail)
await saveMyTischtennisConnection({ ...connection, lastImportError: detail })
throw createError({ statusCode: 502, message: 'TTR-Abruf fehlgeschlagen. Details stehen im Serverlog.' })
}
})

View File

@@ -1,22 +0,0 @@
import { getUserFromToken, hasRole } from '../../../utils/auth.js'
import { androidPushTokenCountForUser, sendTestPushToUser } from '../../../utils/push-notifications.js'
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
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
if (!hasRole(user, 'admin')) throw createError({ statusCode: 403, statusMessage: 'Nur Administratoren dürfen einen Push-Test auslösen.' })
const registeredTokenCount = await androidPushTokenCountForUser(user.id)
try {
const result = await sendTestPushToUser(user.id)
return { success: true, registeredTokenCount, ...result }
} catch (error) {
console.error('FCM Test-Push konnte nicht vorbereitet werden:', error)
return {
success: false,
registeredTokenCount,
deliveryError: 'Die FCM-Serververbindung konnte nicht aufgebaut werden. Details stehen im Serverlog.'
}
}
})

View File

@@ -1,15 +1,18 @@
import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js' import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { formatTeamDisplayName } from '~/utils/team-display.js'
function teamLabel(teamName, teamAgeGroup, competitionAgeGroup) { function teamLabel(teamName, teamAgeGroup) {
return formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup) 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) { function extractHarheimerTeams(rows) {
const seen = new Set() const seen = new Set()
const teams = [] const teams = []
const addTeam = (teamName, teamAgeGroup, competitionAgeGroup) => { const addTeam = (teamName, teamAgeGroup) => {
const name = String(teamName || '').trim() const name = String(teamName || '').trim()
if (!name) return if (!name) return
const age = String(teamAgeGroup || '').trim() const age = String(teamAgeGroup || '').trim()
@@ -18,7 +21,7 @@ function extractHarheimerTeams(rows) {
seen.add(key) seen.add(key)
teams.push({ teams.push({
key, key,
label: teamLabel(name, age, competitionAgeGroup), label: teamLabel(name, age),
teamName: name, teamName: name,
teamAgeGroup: age teamAgeGroup: age
}) })
@@ -26,10 +29,10 @@ function extractHarheimerTeams(rows) {
for (const row of rows || []) { for (const row of rows || []) {
if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') { if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse, row.Altersklasse) addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse)
} }
if (String(row.GastVereinName || '').trim() === 'Harheimer TC') { if (String(row.GastVereinName || '').trim() === 'Harheimer TC') {
addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse, row.Altersklasse) addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse)
} }
} }
@@ -55,4 +58,4 @@ export default defineEventHandler(async (event) => {
seasons, seasons,
teams: extractHarheimerTeams(dataResult.data) teams: extractHarheimerTeams(dataResult.data)
} }
}) })

View File

@@ -22,8 +22,7 @@ export default defineEventHandler(async (event) => {
upsertPushToken(users[userIndex], { upsertPushToken(users[userIndex], {
token: body.token, token: body.token,
platform: body.platform || 'android', platform: body.platform || 'android',
appVersion: body.appVersion || null, appVersion: body.appVersion || null
installationId: body.installationId || null
}) })
await writeUsers(users) await writeUsers(users)
return { success: true, message: 'Push-Token gespeichert.' } return { success: true, message: 'Push-Token gespeichert.' }

View File

@@ -2,7 +2,7 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js' import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { info as loggerInfo, error as loggerError } from '../../utils/logger.js' import { info as loggerInfo, error as loggerError } from '../../utils/logger.js'
import { filterSpielplanRows } from '~/utils/spielplan-filter.js' import { filterSpielplanRows } from '../../../utils/spielplan-filter.js'
function seasonSlugToLabel(slug) { function seasonSlugToLabel(slug) {
const match = String(slug || '').match(/^(\d{2})--(\d{2})$/) const match = String(slug || '').match(/^(\d{2})--(\d{2})$/)

View File

@@ -1,170 +0,0 @@
import { chromium } from 'playwright'
const BASE_URL = 'https://www.mytischtennis.de'
const HEADERS = { 'accept-language': 'de-DE,de;q=0.9', 'user-agent': 'Harheimer-TC-Vereinsverwaltung/1.0' }
function findAuthCookie(headers) {
const raw = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : [])
return raw.find(value => value.startsWith('sb-10-auth-token='))?.split(';')[0] || null
}
function findEntries(value) {
if (!value || typeof value !== 'object') return null
if (Array.isArray(value.entries)) return value.entries
for (const child of Object.values(value)) {
const result = findEntries(child)
if (result) return result
}
return null
}
async function loginDirect(email, password) {
const page = await fetch(`${BASE_URL}/login?next=%2F`, { headers: HEADERS })
const html = await page.text()
if (/captcha/i.test(html)) throw new Error('CAPTCHA_REQUIRED')
const xsrf = html.match(/name=["']xsrf["'][^>]*value=["']([^"']+)/i)?.[1]
const form = new URLSearchParams({ email, password, intent: 'login' })
if (xsrf) form.set('xsrf', xsrf)
const result = await fetch(`${BASE_URL}/login?next=%2F&_data=routes%2F_auth%2B%2Flogin`, {
method: 'POST', headers: { ...HEADERS, 'content-type': 'application/x-www-form-urlencoded' }, body: form, redirect: 'manual'
})
const cookie = findAuthCookie(result.headers)
if (!cookie) throw new Error('Direkter myTischtennis-Login fehlgeschlagen.')
return cookie
}
async function loginWithBrowser(email, password) {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage']
})
try {
const page = await browser.newPage()
// Das myTT-CAPTCHA wird erst nach dem Laden der Seite erzeugt. Der Ablauf
// entspricht der erprobten Integration im Trainingstagebuch.
const acceptConsentDialog = async (waitMs = 0) => {
if (waitMs) await page.waitForTimeout(waitMs)
for (const selector of [
'#onetrust-accept-btn-handler', 'button:has-text("Alle akzeptieren")',
'button:has-text("Akzeptieren")', 'button:has-text("Einverstanden")',
'button:has-text("Zustimmen")', '[data-testid="accept-button"]', '.cmp-accept-all', '.accept-all-btn'
]) {
try {
const button = page.locator(selector).first()
if (await button.count()) { await button.click({ timeout: 2_500 }); await page.waitForTimeout(800); return true }
} catch { /* try next CMP selector */ }
}
return false
}
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {})
if (!await acceptConsentDialog()) await acceptConsentDialog(2_500)
await page.goto(`${BASE_URL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
if (!await acceptConsentDialog()) await acceptConsentDialog(1_500)
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
// Exakt wie in trainingstagebuch: Das Widget wird nach dem DOM-Load
// nachgeladen; ein sofortiges count() würde es häufig übersehen.
await page.waitForSelector('private-captcha', { timeout: 8_000 }).catch(() => {})
const captchaHost = page.locator('private-captcha').first()
const hasCaptcha = await captchaHost.count() > 0
console.info('[mytischtennis] CAPTCHA widget detected:', hasCaptcha)
if (hasCaptcha) {
await page.waitForTimeout(1_200)
// Ein echter Playwright-Klick erzeugt im Gegensatz zu element.click() ein
// vertrauenswürdiges Pointer-Ereignis für das Widget.
await captchaHost.click({ timeout: 5_000, force: true }).catch(error => {
console.warn('[mytischtennis] CAPTCHA pointer click failed:', error.message)
})
await page.evaluate(() => {
const host = document.querySelector('private-captcha')
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox')
if (!checkbox) return
checkbox.click()
checkbox.dispatchEvent(new Event('input', { bubbles: true }))
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
})
await page.waitForFunction(() => {
const token = document.querySelector('input[name="captcha"]')?.value?.trim() || ''
const clicked = document.querySelector('input[name="captcha_clicked"]')?.value?.toLowerCase() || ''
return token.length > 80 && (clicked === 'true' || clicked === '1')
}, { timeout: 32_000 }).catch(() => {})
const captchaState = await page.evaluate(() => {
const host = document.querySelector('private-captcha')
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox')
const token = document.querySelector('input[name="captcha"]')?.value?.trim() || ''
return {
tokenLength: token.length,
clicked: document.querySelector('input[name="captcha_clicked"]')?.value || null,
shadowRoot: Boolean(host?.shadowRoot),
checkboxFound: Boolean(checkbox),
checkboxChecked: Boolean(checkbox?.checked),
widgetState: host?.getAttribute('data-state') || null
}
})
console.info('[mytischtennis] CAPTCHA state after wait:', captchaState)
const captchaReady = captchaState.tokenLength > 80
if (!captchaReady) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.')
await page.waitForTimeout(2_500)
}
await page.evaluate(() => {
const form = document.querySelector('form[action*="/login"]')
if (!form) return
let intent = form.querySelector('input[name="intent"]')
if (!intent) {
intent = document.createElement('input')
intent.setAttribute('type', 'hidden')
intent.setAttribute('name', 'intent')
form.appendChild(intent)
}
intent.setAttribute('value', 'login')
})
const submit = page.locator('button[type="submit"][name="intent"][value="login"]').first()
const genericSubmit = page.locator('button[type="submit"], input[type="submit"]').first()
if (await submit.count()) await submit.click({ noWaitAfter: true })
else if (await genericSubmit.count()) await genericSubmit.click({ noWaitAfter: true })
else await page.locator('form').evaluate(form => form.requestSubmit())
let cookie = null
let cookieNames = []
let loginPageText = ''
for (let attempt = 0; attempt < 40; attempt += 1) {
const cookies = await page.context().cookies()
cookieNames = cookies.map(item => item.name)
cookie = cookies.find(item => item.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(item.name) || item.name.includes('auth-token'))
if (cookie) break
if (attempt % 4 === 0) {
loginPageText = await page.locator('body').innerText().catch(() => '')
}
await page.waitForTimeout(500)
}
if (!cookie) {
console.warn('[mytischtennis] Login after solved CAPTCHA did not create a session', {
url: page.url(), cookieNames, pageText: loginPageText.slice(0, 1_000)
})
throw new Error('myTischtennis-Login nach gelöstem CAPTCHA fehlgeschlagen.')
}
return `${cookie.name}=${cookie.value}`
} finally { await browser.close() }
}
export async function fetchClubRankings(connection, currentRanking = 'yes') {
let cookie
try { cookie = await loginDirect(connection.email, connection.password) } catch (error) {
if (error.message !== 'CAPTCHA_REQUIRED') throw error
cookie = await loginWithBrowser(connection.email, connection.password)
}
const url = new URL('/rankings/andro-rangliste', BASE_URL)
url.search = new URLSearchParams({ 'all-players': 'on', clubnr: connection.clubId, fednickname: connection.association, 'current-ranking': currentRanking, 'results-per-page': '100', page: '0', _data: 'routes/$' })
console.info('[mytischtennis] Ranking request URL:', url.toString())
const response = await fetch(url, { headers: { ...HEADERS, cookie, accept: 'application/json', referer: `${BASE_URL}/` } })
if (!response.ok) throw new Error(`Ranglisten-Abruf fehlgeschlagen (HTTP ${response.status}).`)
const entries = findEntries(await response.json())
if (!entries) throw new Error('myTischtennis hat keine Ranglisten-Daten geliefert.')
console.info('[mytischtennis] Ranking response:', {
currentRanking,
entryCount: entries.length,
entryKeys: Object.keys(entries[0] || {})
})
return entries
}

View File

@@ -1,44 +0,0 @@
import { promises as fs } from 'fs'
import { encryptObject, decryptObject } from './encryption.js'
import { getServerDataPath } from './paths.js'
const FILE = getServerDataPath('mytischtennis-connection.json')
function encryptionKey() {
const key = process.env.ENCRYPTION_KEY
if (!key) throw new Error('ENCRYPTION_KEY ist für die myTischtennis-Verbindung erforderlich.')
return key
}
export async function readMyTischtennisConnection() {
try {
const stored = JSON.parse(await fs.readFile(FILE, 'utf8'))
return decryptObject(stored.encrypted, encryptionKey())
} catch (error) {
if (error?.code === 'ENOENT') return null
throw error
}
}
export async function saveMyTischtennisConnection(connection) {
const payload = {
version: 1,
encrypted: encryptObject(connection, encryptionKey())
}
await fs.mkdir(getServerDataPath(), { recursive: true })
await fs.writeFile(FILE, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 })
}
export function publicConnectionStatus(connection) {
if (!connection) return { configured: false }
const email = String(connection.email || '')
const [name, domain] = email.split('@')
return {
configured: true,
email: name ? `${name.slice(0, 2)}${'•'.repeat(Math.max(1, name.length - 2))}${domain ? `@${domain}` : ''}` : null,
association: connection.association || null,
clubId: connection.clubId || null,
lastSuccessfulImportAt: connection.lastSuccessfulImportAt || null,
lastImportError: connection.lastImportError || null
}
}

View File

@@ -8,7 +8,6 @@ export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
birthdays: false, birthdays: false,
newContactRequest: false, newContactRequest: false,
newUserRegistration: false, newUserRegistration: false,
ownTtrChanges: false,
selectedTeamSlugs: [], selectedTeamSlugs: [],
selectedTeamSeason: null, selectedTeamSeason: null,
notificationTime: '09:00' notificationTime: '09:00'
@@ -42,7 +41,6 @@ export function sanitizeNotificationSettings(input = {}) {
birthdays: coerceBoolean(input.birthdays), birthdays: coerceBoolean(input.birthdays),
newContactRequest: coerceBoolean(input.newContactRequest), newContactRequest: coerceBoolean(input.newContactRequest),
newUserRegistration: coerceBoolean(input.newUserRegistration), newUserRegistration: coerceBoolean(input.newUserRegistration),
ownTtrChanges: coerceBoolean(input.ownTtrChanges),
selectedTeamSlugs: [...new Set(selectedTeamSlugs)], selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
selectedTeamSeason, selectedTeamSeason,
notificationTime notificationTime

View File

@@ -85,21 +85,16 @@ function pushTokensForUser(user) {
: [] : []
} }
export function upsertPushToken(user, { token, platform = 'android', appVersion = null, installationId = null }) { export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
const normalizedToken = String(token || '').trim() const normalizedToken = String(token || '').trim()
if (!normalizedToken) return user if (!normalizedToken) return user
const normalizedInstallationId = String(installationId || '').trim().slice(0, 200) || null
const now = new Date().toISOString() const now = new Date().toISOString()
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : [] const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
// A Firebase token can rotate for the same app installation. Retain tokens const next = tokens.filter(entry => entry?.token !== normalizedToken)
// from other devices, but replace the previous token of this installation.
const next = tokens.filter(entry => entry?.token !== normalizedToken &&
(!normalizedInstallationId || entry?.installationId !== normalizedInstallationId))
next.push({ next.push({
token: normalizedToken, token: normalizedToken,
platform: String(platform || 'android').slice(0, 30), platform: String(platform || 'android').slice(0, 30),
appVersion: appVersion ? String(appVersion).slice(0, 80) : null, appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
installationId: normalizedInstallationId,
updatedAt: now, updatedAt: now,
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
}) })
@@ -107,11 +102,6 @@ export function upsertPushToken(user, { token, platform = 'android', appVersion
return user return user
} }
export async function androidPushTokenCountForUser(userId) {
const user = (await readUsers()).find(entry => entry?.id === userId)
return pushTokensForUser(user).length
}
async function sendFcmMessage({ serviceAccount, accessToken, token, data = {} }) { async function sendFcmMessage({ serviceAccount, accessToken, token, data = {} }) {
const projectId = projectIdFromServiceAccount(serviceAccount) const projectId = projectIdFromServiceAccount(serviceAccount)
if (!projectId) throw new Error('FCM project_id fehlt.') if (!projectId) throw new Error('FCM project_id fehlt.')
@@ -160,26 +150,6 @@ function isVorstandUser(user) {
return roles.includes('admin') || roles.includes('vorstand') return roles.includes('admin') || roles.includes('vorstand')
} }
function normalizePersonName(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/['`]/g, '')
.replace(/\s+/g, ' ')
}
function userMatchesPlayer(user, playerName) {
const player = normalizePersonName(playerName)
if (!player) return false
const candidates = [
user?.name,
`${user?.firstName || ''} ${user?.lastName || ''}`.trim()
].map(normalizePersonName).filter(Boolean)
return candidates.includes(player)
}
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) { export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
const serviceAccount = await readServiceAccount() const serviceAccount = await readServiceAccount()
if (serviceAccount == null) { if (serviceAccount == null) {
@@ -305,57 +275,3 @@ export async function sendNewUserRegistrationPush(registration) {
failureLabel: 'FCM Registrierungs-Push' failureLabel: 'FCM Registrierungs-Push'
}) })
} }
export async function sendOwnTtrChangePush(changes = []) {
const byPlayer = new Map(
changes
.filter(change => change?.playerName && Number.isFinite(change?.previousTtr) && Number.isFinite(change?.currentTtr))
.map(change => [normalizePersonName(change.playerName), change])
)
if (!byPlayer.size) return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: false }
return sendPushToUsers({
title: 'TTR-Wert geändert',
data: { type: 'ttr_change' },
predicate: (user, settings) => settings.ownTtrChanges && [...byPlayer.values()].some(change => userMatchesPlayer(user, change.playerName)),
bodyForUser: (user) => {
const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName))
return `Dein TTR-Wert hat sich von ${change.previousTtr} auf ${change.currentTtr} geändert.`
},
dataForUser: (user) => {
const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName))
return {
previousTtr: change.previousTtr,
currentTtr: change.currentTtr,
notificationId: notificationIdFor(`ttr:${change.playerName}:${change.previousTtr}:${change.currentTtr}`)
}
},
failureLabel: 'FCM TTR-Änderungs-Push'
})
}
export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
return sendPushToUsers({
title: 'QTTR-Liste aktualisiert',
body: `Die aktuelle TTR- und QTTR-Liste wurde aktualisiert (${rowCount} Einträge).`,
data: {
type: 'qttr_list_updated',
importedAt: importedAt || '',
notificationId: notificationIdFor(`qttr-list:${importedAt || Date.now()}`)
},
failureLabel: 'FCM QTTR-Listen-Push'
})
}
export async function sendTestPushToUser(userId) {
return sendPushToUsers({
title: 'Harheimer TC: Push-Test',
body: 'Diese Test-Benachrichtigung wurde erfolgreich vom CMS ausgelöst.',
data: {
type: 'test',
notificationId: notificationIdFor(`test:${userId}:${Date.now()}`)
},
predicate: user => user?.id === userId,
failureLabel: 'FCM Test-Push'
})
}

View File

@@ -1,8 +1,5 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { getServerDataPath } from './paths.js' import { getServerDataPath } from './paths.js'
import { sendOwnTtrChangePush, sendQttrListUpdatedPush } from './push-notifications.js'
import { fetchClubRankings } from './mytischtennis-client.js'
import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js'
const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021' const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021'
const OUTPUT_FILE = getServerDataPath('qttr-values.json') const OUTPUT_FILE = getServerDataPath('qttr-values.json')
@@ -62,8 +59,8 @@ function toNumberOrNull(value) {
function normalizeGender(value) { function normalizeGender(value) {
const normalized = String(value || '').trim().toLowerCase() const normalized = String(value || '').trim().toLowerCase()
if (['m', 'männlich', 'maennlich', 'male', 'man'].includes(normalized)) return 'männlich' if (normalized === 'm' || normalized === 'männlich') return 'männlich'
if (['w', 'weiblich', 'female', 'woman', 'f'].includes(normalized)) return 'weiblich' if (normalized === 'w' || normalized === 'weiblich') return 'weiblich'
return normalized || null return normalized || null
} }
@@ -163,9 +160,6 @@ function deriveQttrFields(headers, cells) {
} }
export async function importQttrValues(options = {}) { export async function importQttrValues(options = {}) {
const connection = options.connection ?? await readMyTischtennisConnection()
if (connection) return importAuthenticatedTtrValues(connection)
const url = options.url || QTTR_URL const url = options.url || QTTR_URL
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
@@ -213,72 +207,4 @@ export async function importQttrValues(options = {}) {
rowCount: parsedRows.length, rowCount: parsedRows.length,
...payload ...payload
} }
} }
async function importAuthenticatedTtrValues(connection) {
const previousPayload = await fs.readFile(OUTPUT_FILE, 'utf8')
.then(JSON.parse)
.catch(() => ({ rows: [] }))
const [ttrEntries, qttrEntries] = await Promise.all([
fetchClubRankings(connection, 'yes'),
fetchClubRankings(connection, 'no')
])
const qttrByPlayer = new Map(qttrEntries.map(entry => [
String(entry.personId || `${entry.firstname || ''}|${entry.lastname || ''}`).toLowerCase(), entry
]))
const rows = ttrEntries.map((entry, index) => {
const key = String(entry.personId || `${entry.firstname || ''}|${entry.lastname || ''}`).toLowerCase()
const qttrEntry = qttrByPlayer.get(key)
return ({
rank: toNumberOrNull(entry.rank ?? entry.ranking ?? index + 1),
playerNumber: toNumberOrNull(entry.playernr ?? entry.player_number ?? entry.nuid),
gender: normalizeGender(entry.gender ?? entry.sex ?? entry.genderCode ?? entry.gender_code),
playerName: `${entry.firstname ?? entry.first_name ?? ''} ${entry.lastname ?? entry.last_name ?? ''}`.trim() || entry.name || null,
clubName: entry.clubname ?? entry.club_name ?? 'Harheimer TC',
currentQttr: toNumberOrNull(qttrEntry?.fedRank ?? qttrEntry?.qttr ?? entry.qttr),
currentTtr: toNumberOrNull(entry.fedRank ?? entry.ttr ?? entry.current_ttr),
previousQttr: null,
valuesByHeader: entry,
rawCells: []
})
}).filter(row => row.playerName && (row.currentTtr != null || row.currentQttr != null))
if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.')
const previousQttrSignature = qttrListSignature(previousPayload.rows)
const nextQttrSignature = qttrListSignature(rows)
const importedAt = new Date().toISOString()
const payload = {
format: 'harheimertc.qttr.v2', importedAt,
source: { type: 'mytischtennis-authenticated', association: connection.association, clubId: connection.clubId },
title: 'Aktuelle myTischtennis-Rangliste', headerCount: 0, rowCount: rows.length, headers: [], rows
}
await fs.mkdir(getServerDataPath(), { recursive: true })
await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
const previousTtrByPlayer = new Map((previousPayload.rows || []).map(row => [
String(row?.playerName || '').trim().toLowerCase(), toNumberOrNull(row?.currentTtr)
]))
const changes = rows.flatMap(row => {
const previousTtr = previousTtrByPlayer.get(String(row.playerName || '').trim().toLowerCase())
return previousTtr != null && row.currentTtr != null && previousTtr !== row.currentTtr
? [{ playerName: row.playerName, previousTtr, currentTtr: row.currentTtr }]
: []
})
await sendOwnTtrChangePush(changes)
if (previousQttrSignature && previousQttrSignature !== nextQttrSignature) {
await sendQttrListUpdatedPush({ importedAt, rowCount: rows.length })
}
await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null })
return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload }
}
function qttrListSignature(rows) {
if (!Array.isArray(rows) || rows.length === 0) return ''
return rows
.map(row => [
String(row?.playerName || '').trim().toLocaleLowerCase('de-DE'),
row?.rank ?? '',
row?.currentQttr ?? ''
].join('|'))
.sort()
.join('\n')
}

View File

@@ -83,7 +83,6 @@ describe('Auth API Endpoints', () => {
afterEach(() => { afterEach(() => {
delete process.env.NODE_ENV delete process.env.NODE_ENV
delete process.env.APP_ENV delete process.env.APP_ENV
delete process.env.DEBUG
delete process.env.NUXT_PUBLIC_BASE_URL delete process.env.NUXT_PUBLIC_BASE_URL
delete process.env.PASSWORD_RESET_TTL_MIN delete process.env.PASSWORD_RESET_TTL_MIN
}) })
@@ -283,7 +282,6 @@ describe('Auth API Endpoints', () => {
it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => { it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => {
process.env.NODE_ENV = 'production' process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test' process.env.APP_ENV = 'test'
delete process.env.DEBUG
const event = createEvent() const event = createEvent()
mockSuccessReadBody({ mockSuccessReadBody({

View File

@@ -247,7 +247,7 @@ describe('Config & Profil Endpoints', () => {
it('speichert Android-Push-Token am Benutzer', async () => { it('speichert Android-Push-Token am Benutzer', async () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' }) mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }] const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.verifyToken.mockReturnValue({ id: '1' })
authUtils.getUserFromToken.mockResolvedValue(users[0]) authUtils.getUserFromToken.mockResolvedValue(users[0])
@@ -260,7 +260,7 @@ describe('Config & Profil Endpoints', () => {
expect(authUtils.writeUsers).toHaveBeenCalledWith([ expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
id: '1', id: '1',
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })] pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
}) })
]) ])
}) })

View File

@@ -1,30 +0,0 @@
/**
* Makes youth teams identifiable while retaining the team name supplied by
* click-TT. Age groups occur in a few forms, for example "Jugend 13",
* "Jugend 13 1. Kreisklasse", and "J13". Some imports provide the age
* only for the whole fixture, therefore `competitionAgeGroup` is considered
* as a fallback.
*/
export function youthTeamCode(teamAgeGroup, teamName = '', competitionAgeGroup = '') {
const ageGroup = [teamAgeGroup, competitionAgeGroup]
.map(value => String(value || '').trim())
.filter(Boolean)
.join(' ')
const name = String(teamName || '').trim()
const codeMatch = `${ageGroup} ${name}`.match(/\b(?:jugend\s*|j\s*\(?)(\d{1,2})\b/i)
if (codeMatch) return `J${codeMatch[1]}`
if (/\bjugend\b/i.test(ageGroup) || /\bjugend\b/i.test(name)) return 'J'
return ''
}
export function formatTeamDisplayName(teamName, teamAgeGroup, competitionAgeGroup = '') {
const rawName = String(teamName || '').trim()
if (!rawName) return ''
const youthCode = youthTeamCode(teamAgeGroup, rawName, competitionAgeGroup)
const name = youthCode
? rawName.replace(/\s*\(J\d{1,2}\)\s*$/i, '').trim()
: rawName
return youthCode ? `(${youthCode}) ${name}` : name
}