19 Commits

Author SHA1 Message Date
45de2a576c Merge pull request 'dev' (#40) 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 2m5s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #40
2026-05-31 15:14:03 +02:00
Torsten Schulz (local)
7bc98c03e4 feat: update Hero component styles and enhance index page layout with dynamic sections
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-05-31 15:04:24 +02:00
Torsten Schulz (local)
bf1caefde4 feat: update security headers and improve content security policy; enhance hero image component and loading states in public news
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m31s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
2026-05-31 14:19:15 +02:00
Torsten Schulz (local)
6983186caf feat: add hero image processing and API for serving variants
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Introduced a new script `prepare-hero-variants.mjs` to generate responsive hero image variants in WebP format.
- Added a fallback image `hero_fallback.png` for each variant.
- Created an API endpoint `hero-images.get.js` to retrieve available hero image variants and their fallback images.
- Implemented directory and file checks to ensure the existence of required images before serving.
2026-05-31 14:07:14 +02:00
e1ad5f7205 Merge pull request 'feat: add robots.txt and sitemap.xml routes for SEO optimization' (#39) 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 2m11s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #39
2026-05-31 14:03:04 +02:00
Torsten Schulz (local)
7c93966878 feat: add robots.txt and sitemap.xml routes for SEO optimization
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
- Implemented a new route for robots.txt to control crawler access.
- Added a sitemap.xml route to provide search engines with a list of site URLs.
- Included functions for URL normalization and XML escaping to ensure proper formatting.
2026-05-31 13:36:49 +02:00
14341b7a63 Merge pull request 'dev' (#38) 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 2m43s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #38
2026-05-30 23:54:07 +02:00
Torsten Schulz (local)
31d20f1bff feat: enhance CompactNavigation with CMS submenu and toggle functionality
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-30 23:51:41 +02:00
Torsten Schulz (local)
6507afea5f feat: add QTTR values feature to member area
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s
- Implemented QTTR values screen in the member area with data fetching and display.
- Added new API endpoint for QTTR values retrieval.
- Created a new view model for managing QTTR data state.
- Updated navigation to include QTTR section.
- Enhanced error handling and loading states for QTTR data.
- Adjusted server-side logic to import QTTR values from external source.
- Updated Android app version and adjusted build configurations.
- Added necessary UI components and styling for QTTR display.
2026-05-30 23:43:06 +02:00
Torsten Schulz (local)
387ce6e08e feat: update ProGuard rules and enhance typography for member area screens
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m14s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m3s
2026-05-30 01:24:39 +02:00
Torsten Schulz (local)
f822fc8a8e feat: update typography styles and enhance text appearance in navigation components
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m8s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-30 00:46:00 +02:00
Torsten Schulz (local)
67c746f18b Add script to generate Play Store screenshot sizes
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
- Introduced a Node.js script (`playstore-screenshot-sizes.mjs`) to resize images for Play Store screenshots based on predefined profiles (phone, tablet-7, tablet-10).
- The script reads images from a specified input directory, processes them, and saves the resized images in an output directory with appropriate naming conventions.
- Added a Bash wrapper script (`playstore-screenshot-sizes.sh`) to execute the Node.js script easily from the command line.
2026-05-30 00:30:50 +02:00
803481ca8e Merge pull request 'dev' (#37) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m15s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m24s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #37
2026-05-30 00:30:30 +02:00
Torsten Schulz (local)
1e65cb47da fix: fallback to npm install on npm ci failure due to lockfile drift
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m31s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-05-29 17:14:11 +02:00
Torsten Schulz (local)
ec96e21517 feat: enhance code analysis workflow with debugging information and workspace cleanup
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 4m1s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-29 17:07:36 +02:00
Torsten Schulz (local)
46f80df165 chore: update version to 1.7.0 in package-lock.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-29 17:02:33 +02:00
46a8d5a77a Merge pull request 'dev' (#36) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m25s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #36
2026-05-28 08:56:09 +02:00
96c1d0260b Merge pull request 'chore(version): update version to 1.6.1' (#35) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m18s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #35
2026-05-27 20:45:54 +02:00
6e00a1b829 Merge pull request 'dev' (#34) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 33s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #34
2026-05-27 20:43:46 +02:00
108 changed files with 2315 additions and 209 deletions

View File

@@ -6,10 +6,27 @@ on:
jobs:
analyze:
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
- name: Ensure clean workspace
run: |
git reset --hard HEAD
git clean -fdx
- name: Debug dependency files
run: |
echo "commit: $(git rev-parse HEAD)"
echo "branch ref: ${GITHUB_REF:-unknown}"
echo "package.json checksum:" && sha256sum package.json
echo "package-lock.json checksum:" && sha256sum package-lock.json
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -72,7 +89,11 @@ jobs:
rm -f gitleaks.tar.gz
- name: Install dependencies
run: npm ci
run: |
if ! npm ci; then
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
npm install
fi
- name: Lint
run: npm run lint
@@ -98,9 +119,8 @@ jobs:
./osv-scanner --lockfile ./package-lock.json
deploy-production:
needs: analyze
runs-on: ubuntu-latest
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Prepare SSH
run: |

1
.gitignore vendored
View File

@@ -93,6 +93,7 @@ dist
/android-app/.kotlin/
/android-app/**/build/
/android-app/local.properties
/android-app/gradle-local.properties
# Build output (but keep production data!)
.output

View File

@@ -36,13 +36,12 @@ Ausgabe in:
## 3) Screenshots (anonymisiert)
### Grobe Anforderungen (Telefon)
- Mindestens 2 Screenshots
- PNG oder JPEG
- Seitenlaenge je Seite zwischen 320 px und 3840 px
### Zielgroessen fuer Store-Upload
- Telefon (Portrait): 1080 x 1920
- Medium 7" Tablet (Portrait): 1200 x 1920
- 10" Tablet (Portrait): 1600 x 2560
Empfehlung fuer Android-Phone:
- 1080 x 1920 (Portrait)
Alle Dateien als PNG oder JPEG.
### Anonymisierung
@@ -61,10 +60,33 @@ Beispiel:
'68,118,520,72;70,706,560,98'
```
### Zielprofile erzeugen (Telefon, 7", 10")
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
```bash
./scripts/playstore-screenshot-sizes.sh
```
Optional mit eigenen Ordnern:
```bash
./scripts/playstore-screenshot-sizes.sh \
--input-dir android-app/playstore-assets/anon \
--output-dir android-app/playstore-assets/final
```
Output:
- android-app/playstore-assets/final/phone
- android-app/playstore-assets/final/tablet-7
- android-app/playstore-assets/final/tablet-10
## 4) Upload in Play Console
- Datenschutzerklaerung: URL eintragen
- Konto-Loeschung: URL eintragen
- App-Icon: playstore-icon-512.png
- Feature Graphic: playstore-feature-graphic-1024x500.png
- Screenshots: anonymisierte PNG/JPEG hochladen
- Screenshots Telefon: Dateien aus `.../final/phone`
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
@@ -25,15 +27,40 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
.orElse("true")
.get()
.toBoolean()
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
val localSigningProperties = Properties().apply {
val localSigningFile = rootProject.file("gradle-local.properties")
if (localSigningFile.exists()) {
localSigningFile.inputStream().use { load(it) }
}
}
fun signingProperty(name: String): String? =
providers.gradleProperty(name).orNull
?: providers.environmentVariable(name).orNull
?: localSigningProperties.getProperty(name)
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank()
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
doFirst {
if (!hasReleaseSigning) {
throw GradleException(
"Production release signing is not configured. " +
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
)
}
}
}
android {
namespace = "de.harheimertc"
compileSdk = 35
@@ -135,6 +162,7 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning)
dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease)
@@ -161,6 +189,12 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
}
}
tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach {
dependsOn(ensureReleaseSigning)
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)

View File

@@ -1,2 +1,37 @@
# Project-specific R8/ProGuard rules for release builds.
# Keep this file intentionally minimal and add rules only when needed.
# Keep reflection/generic metadata used by Retrofit + Moshi.
-keepattributes Signature,InnerClasses,EnclosingMethod
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
-keep class kotlin.Metadata { *; }
# Keep Retrofit service interfaces and HTTP method annotations.
-keep,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Avoid Retrofit generic signature loss on release builds for our API interface.
-keep interface de.harheimertc.data.ApiService { *; }
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
# Keep app DTO/request/response models used via Moshi reflection.
-keep class de.harheimertc.data.*Dto { *; }
-keep class de.harheimertc.data.*Request { *; }
-keep class de.harheimertc.data.*Response { *; }
# Keep fields annotated with @Json names.
-keepclassmembers class * {
@com.squareup.moshi.Json <fields>;
}
# Keep WorkManager + Room generated classes used reflectively at startup.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
-keep class * extends androidx.room.RoomDatabase { *; }

View File

@@ -81,7 +81,7 @@ data class LeagueTableRowDto(
)
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto(
val id: Int? = null,
val id: String? = null,
val title: String = "",
val content: String = "",
val created: String? = null,
@@ -96,7 +96,7 @@ data class NewsResponse(
val news: List<NewsDto> = emptyList(),
)
data class NewsSaveRequest(
val id: Int? = null,
val id: String? = null,
val title: String,
val content: String,
val isPublic: Boolean = false,
@@ -260,6 +260,28 @@ data class BirthdaysResponse(
val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(),
)
data class QttrSourceDto(
val url: String = "",
)
data class QttrRowDto(
val rank: Int? = null,
val playerNumber: Int? = null,
val gender: String? = null,
val playerName: String = "",
val clubName: String = "",
val currentQttr: Int? = null,
val previousQttr: Int? = null,
val birthdate: String? = null,
)
data class QttrValuesResponse(
val format: String = "",
val importedAt: String = "",
val source: QttrSourceDto = QttrSourceDto(),
val title: String? = null,
val headerCount: Int = 0,
val rowCount: Int = 0,
val rows: List<QttrRowDto> = emptyList(),
)
data class MemberDto(
val id: String? = null,
val name: String = "",
@@ -387,6 +409,15 @@ data class HomepageSectionConfigDto(
data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(),
)
data class HeroImageVariantDto(
val key: String = "",
val mobileWebp: String = "",
val desktopWebp: String = "",
val fallback: String = "",
)
data class HeroImagesResponse(
val variants: List<HeroImageVariantDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
val geschichte: String = "",
@@ -548,7 +579,7 @@ interface ApiService {
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@@ -556,6 +587,9 @@ interface ApiService {
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@GET("/api/hero-images")
suspend fun heroImages(): Response<HeroImagesResponse>
@PUT("/api/config")
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
@@ -620,6 +654,9 @@ interface ApiService {
@GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse>
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/members")
suspend fun members(): Response<MembersResponse>

View File

@@ -16,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
) {
private companion object {
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values"
const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config"
@@ -43,6 +44,9 @@ class SecureOfflineCache @Inject constructor(
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
@@ -93,6 +97,7 @@ class SecureOfflineCache @Inject constructor(
KEY_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS,
KEY_QTTR_VALUES,
)
}

View File

@@ -267,7 +267,7 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache()

View File

@@ -1,12 +1,16 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.HeroImageVariantDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import io.sentry.Sentry
import kotlin.random.Random
import javax.inject.Inject
import javax.inject.Singleton
@@ -17,20 +21,152 @@ data class HomeData(
val selectedSpielplanSeason: String?,
val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
val heroImageUrl: String? = null,
val diagnostics: List<String> = emptyList(),
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty()
val spielplanResponse = api.spielplan().body()
val spiele = spielplanResponse?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
val homepageSections = runCatching {
val configResponse = api.config()
if (!configResponse.isSuccessful) return@runCatching emptyList()
configResponse.body()?.homepage?.sections.orEmpty()
val diagnostics = mutableListOf<String>()
val termine = runCatching {
val response = api.termine()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.termine.orEmpty()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val spielplanResponse = runCatching {
val response = api.spielplan()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
val spiele = spielplanResponse?.data.orEmpty()
val news = runCatching {
val response = api.publicNews()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.news.orEmpty()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val homepageSections = runCatching {
val response = api.config()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.homepage?.sections.orEmpty()
}.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val heroImageUrl = runCatching {
val response = api.heroImages()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
}
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
}.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
HomeData(
termine = termine,
spiele = spiele,
@@ -38,12 +174,75 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
selectedSpielplanSeason = spielplanResponse?.season,
news = news,
homepageSections = homepageSections,
heroImageUrl = heroImageUrl,
diagnostics = diagnostics,
)
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchHomeData")
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchSpielplanForSeason")
scope.setExtra("season", season)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun captureLoadIssue(operation: String, error: Throwable) {
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", operation)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun buildDiagnostic(
endpoint: String,
requestPayload: String,
httpCode: Int?,
responseBody: String?,
throwable: Throwable?,
): String {
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
return buildString {
append("Endpoint: ").append(endpoint).append('\n')
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
append("Request: ").append(requestPayload).append('\n')
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
append("Response: ").append(responsePreview).append('\n')
append("Throwable: ").append(throwableInfo)
}
}
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
if (variants.isEmpty()) return null
val valid = variants.filter { it.fallback.isNotBlank() }
if (valid.isEmpty()) return null
val selected = valid[Random.nextInt(valid.size)]
return toAbsoluteUrl(selected.fallback)
}
private fun toAbsoluteUrl(pathOrUrl: String): String {
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
return pathOrUrl
}
val base = BuildConfig.API_BASE_URL.trimEnd('/')
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
return "$base$path"
}
}

View File

@@ -1,5 +1,6 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import io.sentry.Sentry
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val endpoint = "api/auth/login"
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body)
val fallback = when (response.code()) {
401 -> "Ungueltige Anmeldedaten"
403 -> "Konto nicht freigeschaltet"
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
else -> "Anmeldung fehlgeschlagen"
}
val diagnostic = buildString {
append("\n\nDiagnose:\n")
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
append("Request: ").append(requestPreview).append('\n')
append("HTTP: ").append(response.code()).append('\n')
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
append("Server message: ").append(serverMessage ?: "none")
}
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
}
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "login")
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun logout(): Result<Unit> = runCatching {
@@ -51,6 +81,15 @@ class LoginRepository @Inject constructor(
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "status")
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
@@ -64,4 +103,19 @@ class LoginRepository @Inject constructor(
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
private fun extractServerMessage(raw: String): String? {
if (raw.isBlank()) return null
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
}
private fun maskEmail(rawEmail: String): String {
val email = rawEmail.trim()
if (!email.contains('@')) return "hidden"
val local = email.substringBefore('@')
val domain = email.substringAfter('@')
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
return "$localMasked@$domain"
}
}

View File

@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.QttrValuesResponse
import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
)
suspend fun qttrValues(): Result<QttrValuesResponse> =
fetchEncryptedFallback(
load = {
val response = api.qttrValues()
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putQttrValues,
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
)
suspend fun members(): Result<MembersResponse> =
fetchEncryptedFallback(
load = {
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
response.body() ?: emptyMap()
}
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
}

View File

@@ -1,10 +1,12 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -72,16 +74,131 @@ private fun CompactNavigation(
onNavigate: (String) -> Unit,
navigationState: NavigationUiState = NavigationUiState(),
) {
val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val subItems = submenu(section, navigationState)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
val mainScroll = rememberScrollState()
val subScroll = rememberScrollState()
val cmsSubScroll = rememberScrollState()
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.horizontalScroll(mainScroll),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
}
if (mainScroll.canScrollBackward) {
Text(
"",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.CenterStart)
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
if (mainScroll.canScrollForward) {
Text(
"",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.CenterEnd)
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
}
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(subScroll)
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
}
} else if (idx > cmsIndex && cmsIndex >= 0) {
// CMS children are rendered below when expanded.
} else {
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
}
}
}
if (subItems.isNotEmpty()) {
ScrollHintRow(subScroll)
}
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(cmsSubScroll)
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
}
}
ScrollHintRow(cmsSubScroll)
}
}
@Composable
private fun CompactSectionLink(
label: String,
section: MenuSection,
activeSection: MenuSection?,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
color = if (activeSection == section) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onSelect() },
) {
Text(
label,
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@@ -107,7 +224,6 @@ private fun WebTabletNavigation(
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
@@ -214,8 +330,8 @@ private fun MainLink(
) {
Text(
label,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
maxLines = 1,
)
@@ -229,23 +345,51 @@ private fun CompactLink(
selectedRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
beforeNavigate: () -> Unit = {},
) {
Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) },
modifier = modifier.clickable {
beforeNavigate()
onNavigate(route)
},
) {
Text(
label,
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@Composable
private fun ScrollHintRow(scrollState: ScrollState) {
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
)
Text(
if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
)
}
}
@Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
@@ -256,7 +400,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Text(
label,
color = if (selected) Color.White else Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
maxLines = 1,
)
@@ -289,6 +433,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
@@ -339,6 +484,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))

View File

@@ -36,6 +36,7 @@ sealed class Destinations(val route: String) {
object Register : Destinations("register")
object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder")
object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object MemberApi : Destinations("intern/api")

View File

@@ -261,6 +261,12 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Qttr.route) {
de.harheimertc.ui.screens.memberarea.QttrScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberNews.route) {
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
navController = navController,

View File

@@ -49,7 +49,7 @@ import java.util.Locale
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
var selection by remember { mutableStateOf(setOf<Int>()) }
var selection by remember { mutableStateOf(setOf<String>()) }
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
// Local dialog state for create/edit + delete confirmation (hoisted)
var dialogOpen by remember { mutableStateOf(false) }
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
var editing by remember { mutableStateOf<NewsDto?>(null) }
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
@@ -256,9 +256,9 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
private fun NewsListItem(
news: NewsDto,
selected: Boolean = false,
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit,
onDelete: (Int) -> Unit,
onDelete: (String) -> Unit,
) {
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {

View File

@@ -207,7 +207,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -229,7 +229,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -251,7 +251,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkDelete(ids: List<Int>) {
fun bulkDelete(ids: List<String>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -262,7 +262,7 @@ class CmsViewModel @Inject constructor(
}
}
fun deleteNews(id: Int) {
fun deleteNews(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id)

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -52,7 +53,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
@@ -126,11 +126,54 @@ fun HomeScreen(
onReset = viewModel::resetSections,
)
}
if (state.error) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::load) {
Text("Erneut versuchen")
}
}
}
}
if (state.debugDiagnostics.isNotEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Technische Diagnose (vorübergehend)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
}
}
}
state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section)
when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
"banner" -> item(key = "home_section_${sectionKey}_$index") {
WebHero(imageUrl = state.heroImageUrl)
}
"termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection(
termine = state.termine,
@@ -454,7 +497,7 @@ private fun HomeSpielplanTeamWidgetSection(
}
@Composable
private fun WebHero() {
private fun WebHero(imageUrl: String?) {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box(
modifier = Modifier
@@ -463,12 +506,14 @@ private fun WebHero() {
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop,
)
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop,
)
}
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
data class HomeUiState(
val loading: Boolean = true,
val heroImageUrl: String? = null,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
@@ -44,6 +45,8 @@ data class HomeUiState(
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false,
val error: Boolean = false,
val errorMessage: String? = null,
val debugDiagnostics: List<String> = emptyList(),
)
@HiltViewModel
@@ -62,7 +65,12 @@ class HomeViewModel @Inject constructor(
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = false)
_state.value = _state.value.copy(
loading = true,
error = false,
errorMessage = null,
debugDiagnostics = emptyList(),
)
repository.fetchHomeData()
.onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections)
@@ -80,6 +88,7 @@ class HomeViewModel @Inject constructor(
)
_state.value = HomeUiState(
loading = false,
heroImageUrl = data.heroImageUrl,
termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() }
@@ -99,10 +108,16 @@ class HomeViewModel @Inject constructor(
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
debugDiagnostics = data.diagnostics,
)
}
.onFailure {
_state.value = HomeUiState(loading = false, error = true)
.onFailure { err ->
_state.value = HomeUiState(
loading = false,
error = true,
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
)
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
@@ -25,15 +26,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import android.util.Log
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
@@ -166,7 +171,14 @@ fun MembersScreen(
val m = display[index]
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(Modifier.weight(1f)) { Text(m.name, color = Accent900) }
Column(Modifier.weight(1f)) {
Text(
m.name,
color = Accent900,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.ExtraBold,
)
}
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
}
@@ -200,7 +212,7 @@ fun MemberNewsScreen(
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
val groups = listOf(
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/mitgliederbereich/qttr", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
)
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
@@ -229,6 +241,42 @@ fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
}
}
@Composable
fun QttrScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: QttrViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
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 {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(state.title ?: "Andro-Rangliste", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("${state.rows.size} Einträge · Aktualisiert ${state.importedAt ?: "unbekannt"}", color = Accent500)
}
}
}
when {
state.loading -> item { CircularProgressIndicator(color = Primary600) }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
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)) }
}
}
}
@Composable
private fun MemberAreaPage(
navController: NavController,
@@ -259,7 +307,13 @@ private fun MemberAreaPage(
private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(
member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() },
style = MaterialTheme.typography.titleLarge,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.ExtraBold,
color = Accent900,
)
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
if (!member.birthday.isNullOrBlank()) {
@@ -308,6 +362,83 @@ private fun Badge(label: String) {
}
}
@Composable
private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
Surface(
color = if (highlighted) Primary100 else Color.White,
shape = RoundedCornerShape(14.dp),
shadowElevation = 3.dp,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
row.playerName.ifBlank { "Unbekannt" },
style = MaterialTheme.typography.titleMedium,
color = qttrNameColor(row.gender, isMinor(row.birthdate)),
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
)
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
}
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
}
private fun isOwnRow(playerName: String?, currentUserName: String): Boolean {
fun normalize(value: String?): String {
return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD)
.replace(Regex("[\\u0300-\\u036f]"), "")
.replace(Regex("['`]"), "")
.replace(Regex("\\s+"), " ")
}
val current = normalize(currentUserName)
if (current.isBlank()) return false
return normalize(playerName) == current
}
private fun qttrNameColor(gender: String?, isMinor: Boolean): Color {
val value = gender.orEmpty().trim().lowercase()
return when {
value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB)
value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D)
else -> Accent900
}
}
private fun isMinor(birthdate: String?): Boolean {
val date = parseBirthdate(birthdate) ?: return false
val today = java.time.LocalDate.now()
var age = today.year - date.year
if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) {
age -= 1
}
return age < 18
}
private fun parseBirthdate(value: String?): java.time.LocalDate? {
val raw = value.orEmpty().trim()
if (raw.isBlank()) return null
return try {
if (Regex("^\\d{4}$").matches(raw)) {
java.time.LocalDate.of(raw.toInt(), 1, 1)
} else {
java.time.LocalDate.parse(raw)
}
} catch (_: Exception) {
null
}
}
@Composable
private fun ErrorCard(message: String, onRetry: () -> Unit) {
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {

View File

@@ -3,9 +3,12 @@ package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.QttrRowDto
import de.harheimertc.repositories.MemberAreaRepository
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -101,3 +104,45 @@ class MemberNewsViewModel @Inject constructor(
}
}
}
data class QttrUiState(
val rows: List<QttrRowDto> = emptyList(),
val title: String? = null,
val importedAt: String? = null,
val currentUserName: String = "",
val loading: Boolean = true,
val error: String? = null,
)
@HiltViewModel
class QttrViewModel @Inject constructor(
private val repository: MemberAreaRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _state = MutableStateFlow(QttrUiState())
val state: StateFlow<QttrUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse())
repository.qttrValues()
.onSuccess { response ->
_state.value = _state.value.copy(
rows = response.rows,
title = response.title,
importedAt = response.importedAt,
currentUserName = authStatus.user?.name.orEmpty(),
loading = false,
)
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.")
}
}
}
}

View File

@@ -95,6 +95,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
marker = "N",
onClick = { navController.navigate(Destinations.MemberNews.route) },
)
MemberAreaCard(
title = "QTTR",
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
marker = "Q",
onClick = { navController.navigate(Destinations.Qttr.route) },
)
}
}

View File

@@ -10,32 +10,91 @@ import de.harheimertc.R
// Bundled variable fonts in res/font:
val InterFamily = FontFamily(Font(R.font.inter_variable))
val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold))
val MontserratFamily = FontFamily(
Font(R.font.montserrat_variable, FontWeight.SemiBold),
Font(R.font.montserrat_variable, FontWeight.Bold),
Font(R.font.montserrat_variable, FontWeight.ExtraBold),
)
// Android headings: use system sans-serif for stronger strokes/readability on tablets.
val HeaderFamily = FontFamily.SansSerif
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = MontserratFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 30.sp
fontFamily = HeaderFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
lineHeight = 38.sp
),
headlineLarge = TextStyle(
fontFamily = HeaderFamily,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp
),
headlineMedium = TextStyle(
fontFamily = HeaderFamily,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 30.sp
),
headlineSmall = TextStyle(
fontFamily = HeaderFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 26.sp
),
titleLarge = TextStyle(
fontFamily = MontserratFamily,
fontWeight = FontWeight.Medium,
fontSize = 20.sp
fontFamily = HeaderFamily,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = HeaderFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp
),
titleSmall = TextStyle(
fontFamily = HeaderFamily,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
lineHeight = 22.sp
),
bodyLarge = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 25.sp
),
bodyMedium = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp
),
bodySmall = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
lineHeight = 20.sp
),
labelMedium = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 19.sp
),
labelSmall = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
lineHeight = 18.sp
)
)

View File

@@ -0,0 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="io.sentry.android.core.SentryInitProvider"
tools:node="remove" />
<provider
android:name="io.sentry.android.core.SentryPerformanceProvider"
tools:node="remove" />
</application>
</manifest>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
# Copy this file to android-app/gradle-local.properties (ignored by git)
# and fill in your release signing credentials.
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
RELEASE_STORE_PASSWORD=
RELEASE_KEY_ALIAS=
RELEASE_KEY_PASSWORD=

View File

@@ -8,14 +8,15 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=4
ANDROID_VERSION_NAME=1.0.0
ANDROID_VERSION_CODE=19
ANDROID_VERSION_NAME=0.9.14
# Enable R8 for release by default so mapping.txt is generated for Play Console.
RELEASE_MINIFY_ENABLED=true
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false
# Release signing (set in local, untracked gradle.properties or via CI secrets)
# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
# Keep secrets out of git. Use ~/.gradle/gradle.properties or environment variables.
# RELEASE_STORE_PASSWORD=***
# RELEASE_KEY_ALIAS=***
# RELEASE_KEY_PASSWORD=***

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -32,7 +32,7 @@
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de"
# Optional: Vollständige Content Security Policy (zusätzlich zu frame-ancestors)
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# Proxy alle Anfragen an Nuxt Server (Port 3100)
ProxyPreserveHost On

View File

@@ -32,7 +32,7 @@
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de"
# Optional: Vollständige Content Security Policy (zusätzlich zu frame-ancestors)
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# SPA Fallback für Nuxt.js
<Directory "/var/www/harheimertc/dist">

View File

@@ -4,12 +4,12 @@
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
scroll-behavior: smooth;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Montserrat', system-ui, sans-serif;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
}
}

View File

@@ -0,0 +1,20 @@
# Hero-Originalbilder
Das Tool `npm run hero:prepare` unterstuetzt zwei Eingabeformate:
1. Unterordner pro Variante (empfohlen)
- `assets/images/hero-originals/<variante>/hero.png`
2. Flache Ablage von PNGs
- `public/images/hero-originals/hero1.png`
- `public/images/hero-originals/hero2.png`
Ausgabe je Variante in:
- `public/images/hero/<variante>/hero_960.webp`
- `public/images/hero/<variante>/hero_1600.webp`
- `public/images/hero/<variante>/hero_fallback.png`
Die Startseite (`components/Hero.vue`) waehlt danach automatisch zufaellig eine vorhandene Variante aus.

View File

@@ -1,21 +1,35 @@
<template>
<section
id="home"
class="relative min-h-full flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
class="hero-shell relative overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
>
<!-- Decorative Elements -->
<div class="absolute inset-0 z-0">
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-200/30 rounded-full blur-3xl" />
<div class="absolute bottom-0 left-0 w-96 h-96 bg-gray-300/30 rounded-full blur-3xl" />
<!-- Hintergrundbild -->
<div
class="absolute inset-0 opacity-10"
style="background-image: url('/images/club_about_us.png'); background-size: cover; background-position: center;"
/>
<picture class="absolute inset-0 opacity-15">
<source
v-if="heroImage.mobileWebp && heroImage.desktopWebp"
type="image/webp"
:srcset="`${heroImage.mobileWebp} 960w, ${heroImage.desktopWebp} 1600w`"
sizes="(max-width: 1024px) 960px, 1600px"
>
<img
:src="heroImage.fallback"
alt=""
aria-hidden="true"
class="w-full h-full object-cover object-[center_36%]"
width="1600"
height="900"
loading="eager"
fetchpriority="high"
decoding="async"
>
</picture>
</div>
<!-- Content -->
<div class="relative z-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-8">
<div class="relative z-20 max-w-7xl mx-auto">
<div class="text-center">
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-display font-bold text-gray-900 mb-6 leading-tight animate-fade-in">
Willkommen beim<br>
@@ -31,11 +45,101 @@
</template>
<script setup>
import { computed } from 'vue'
import { useFetch, useHead, useState } from '#imports'
function buildInlineFallback() {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#eef2f7" />
<stop offset="100%" stop-color="#d8e0ea" />
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#g)" />
</svg>`
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
}
const DEFAULT_HERO_IMAGE = {
key: 'fallback',
mobileWebp: '',
desktopWebp: '',
fallback: buildInlineFallback()
}
const { data: heroImagesResponse } = await useFetch('/api/hero-images')
const heroVariants = computed(() => {
const variants = heroImagesResponse.value?.variants
return Array.isArray(variants) && variants.length ? variants : [DEFAULT_HERO_IMAGE]
})
function pickRandomHeroImage(variants) {
const list = Array.isArray(variants) && variants.length ? variants : [DEFAULT_HERO_IMAGE]
const index = Math.floor(Math.random() * list.length)
return list[index]
}
const heroImageState = useState('home-hero-image', () => pickRandomHeroImage(heroVariants.value))
if (!heroVariants.value.some((variant) => variant.key === heroImageState.value?.key)) {
heroImageState.value = pickRandomHeroImage(heroVariants.value)
}
const heroImage = computed(() => heroImageState.value)
const preloadLinks = computed(() => {
const links = []
if (heroImage.value.mobileWebp) {
links.push({
rel: 'preload',
as: 'image',
href: heroImage.value.mobileWebp,
type: 'image/webp',
media: '(max-width: 1024px)'
})
}
if (heroImage.value.desktopWebp) {
links.push({
rel: 'preload',
as: 'image',
href: heroImage.value.desktopWebp,
type: 'image/webp',
media: '(min-width: 1025px)'
})
}
return links
})
useHead(() => ({
link: preloadLinks.value
}))
const foundingYear = 1954
const yearsSinceFounding = new Date().getFullYear() - foundingYear
</script>
<style scoped>
.hero-shell {
min-height: 430px;
}
@media (min-width: 1024px) {
.hero-shell {
min-height: 540px;
}
}
@media (min-aspect-ratio: 21/9) {
.hero-shell {
min-height: 640px;
}
}
@keyframes fadeIn {
from {
opacity: 0;

View File

@@ -274,6 +274,13 @@
>
Mitgliederliste
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/qttr"
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"
>
QTTR
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/news"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
@@ -714,6 +721,13 @@
>
Mitgliederliste
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/qttr"
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"
>
QTTR
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/news"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@@ -814,7 +828,7 @@
Einstellungen
</NuxtLink>
<NuxtLink
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
v-if="canManageUsers"
to="/cms/benutzer"
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"
@@ -849,34 +863,13 @@ const mobileSubmenu = ref(null)
const mannschaften = ref([])
const hasGalleryImages = ref(false)
const showCmsDropdown = ref(false)
const authStore = useAuthStore()
// Lazy store access to avoid Pinia initialization issues
const getAuthStore = () => {
try {
return useAuthStore()
} catch (e) {
// Fallback if Pinia is not yet initialized
return null
}
}
// Reactive auth state from store (lazy)
const isLoggedIn = computed(() => {
const store = getAuthStore()
return store?.isLoggedIn ?? false
})
const isAdmin = computed(() => {
const store = getAuthStore()
return store?.isAdmin ?? false
})
const canAccessNewsletter = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
})
const canAccessContactRequests = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
})
const isLoggedIn = computed(() => authStore.isLoggedIn)
const isAdmin = computed(() => authStore.isAdmin)
const canAccessNewsletter = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'newsletter'))
const canAccessContactRequests = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'trainer'))
const canManageUsers = computed(() => authStore.hasAnyRole('admin', 'vorstand'))
// Automatisches Setzen des Submenus basierend auf der Route
const currentSubmenu = computed(() => {
@@ -982,10 +975,7 @@ const handleDocumentClick = (e) => {
onMounted(() => {
loadMannschaften()
checkGalleryImages()
const store = getAuthStore()
if (store) {
store.checkAuth()
}
authStore.checkAuth()
// Close CMS dropdown when clicking outside
document.addEventListener('click', handleDocumentClick)

View File

@@ -1,7 +1,6 @@
<template>
<section
v-if="news.length > 0"
class="py-16 sm:py-20 bg-white"
class="py-16 sm:py-20 bg-white min-h-[32rem]"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
@@ -14,7 +13,29 @@
</p>
</div>
<div class="flex justify-center">
<div
v-if="isLoading"
class="grid gap-8 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="placeholder in 3"
:key="`news-placeholder-${placeholder}`"
class="bg-gray-50 rounded-xl p-6 border border-gray-200"
>
<div class="h-4 w-32 bg-gray-200 rounded animate-pulse mb-4" />
<div class="h-7 w-3/4 bg-gray-200 rounded animate-pulse mb-4" />
<div class="space-y-2">
<div class="h-4 w-full bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
<div
v-else-if="news.length > 0"
class="flex justify-center"
>
<div
class="grid gap-8"
:class="getGridClass()"
@@ -43,6 +64,18 @@
</article>
</div>
</div>
<div
v-else
class="max-w-xl mx-auto text-center bg-gray-50 border border-gray-200 rounded-xl p-8"
>
<p class="text-gray-700 font-semibold mb-2">
Aktuell keine News
</p>
<p class="text-gray-600 text-sm">
Neue Vereinsnachrichten erscheinen hier automatisch.
</p>
</div>
</div>
<!-- News Modal -->
@@ -91,13 +124,16 @@ import { Calendar, X } from 'lucide-vue-next'
const news = ref([])
const selectedNews = ref(null)
const isLoading = ref(true)
const loadNews = async () => {
try {
const response = await $fetch('/api/news-public')
news.value = response.news
news.value = Array.isArray(response?.news) ? response.news : []
} catch (error) {
console.error('Fehler beim Laden der öffentlichen News:', error)
} finally {
isLoading.value = false
}
}

View File

@@ -57,7 +57,10 @@ has_tracked_files_under() {
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
if ! npm ci; then
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
npm install
fi
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
@@ -92,6 +95,10 @@ install_dependencies_if_needed() {
echo " package-lock.json unverändert, überspringe npm ci"
fi
if [ -f "package-lock.json" ]; then
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}

View File

@@ -70,7 +70,10 @@ has_tracked_files_under() {
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
if ! npm ci; then
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
npm install
fi
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
@@ -105,6 +108,10 @@ install_dependencies_if_needed() {
echo " package-lock.json unverändert, überspringe npm ci"
fi
if [ -f "package-lock.json" ]; then
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}

View File

@@ -76,13 +76,7 @@ export default defineNuxtConfig({
{ property: 'twitter:description', content: 'Offizielle Website des Harheimer Tischtennis-Club 1954 e.V.' }
],
link: [
{ rel: 'canonical', href: 'https://www.harheimertc.de/' },
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@700;800;900&display=swap'
}
{ rel: 'canonical', href: 'https://www.harheimertc.de/' }
]
}
},

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "harheimertc-website",
"version": "1.6.2",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "harheimertc-website",
"version": "1.6.2",
"version": "1.7.0",
"hasInstallScript": true,
"dependencies": {
"@pinia/nuxt": "^0.11.2",

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.7.0",
"version": "1.8.0",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",
@@ -19,10 +19,12 @@
"check-security": "node scripts/verify-no-public-writes.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",
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
"import-spielplan": "node scripts/import-spielplan.js",
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
"playstore:assets": "./scripts/playstore-assets.sh",
"playstore:anonymize": "./scripts/anonymize-playstore-screenshot.sh",
"playstore:screenshots": "./scripts/playstore-screenshot-sizes.sh",
"test:watch": "vitest watch",
"lint": "eslint . --fix"
},

View File

@@ -207,8 +207,31 @@
</div>
</div>
<template v-if="heroSection">
<component :is="getComponentForSection(heroSection.id)" />
</template>
<div
v-if="featuredWidgetSection"
class="relative z-30 px-4 sm:px-6 lg:px-8"
:class="hasHeroSection ? '-mt-44 sm:-mt-48 lg:-mt-52' : 'mt-8'"
>
<div class="featured-widget-shell max-w-6xl mx-auto rounded-2xl border border-gray-200 bg-white/25 backdrop-blur-md overflow-hidden min-h-[22rem] sm:min-h-[25rem]">
<HomeSpielplanTeamWidget
v-if="featuredWidgetSection.id === 'spielplan_team'"
:season="featuredWidgetSection.config?.season"
:team-name="featuredWidgetSection.config?.teamName"
:team-age-group="featuredWidgetSection.config?.teamAgeGroup"
/>
<component
:is="getComponentForSection(featuredWidgetSection.id)"
v-else
/>
</div>
</div>
<template
v-for="section in enabledSections"
v-for="section in remainingWidgetSections"
:key="section.key"
>
<HomeSpielplanTeamWidget
@@ -226,17 +249,39 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, h, ref } from 'vue'
import { SlidersHorizontal, X } from 'lucide-vue-next'
import Hero from '~/components/Hero.vue'
import HomeTermine from '~/components/HomeTermine.vue'
import Spielplan from '~/components/Spielplan.vue'
import PublicNews from '~/components/PublicNews.vue'
import HomeActions from '~/components/HomeActions.vue'
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.vue'
const SectionLoadingPlaceholder = {
name: 'SectionLoadingPlaceholder',
render() {
return h('section', { class: 'py-16 sm:py-20 bg-white' }, [
h('div', { class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }, [
h('div', { class: 'h-10 max-w-xs mx-auto rounded bg-gray-200 animate-pulse mb-8' }),
h('div', { class: 'h-56 rounded-2xl bg-gray-100 animate-pulse' })
])
])
}
}
function loadHomeSection(loader) {
return defineAsyncComponent({
loader,
loadingComponent: SectionLoadingPlaceholder,
delay: 0,
suspensible: false
})
}
const HomeTermine = loadHomeSection(() => import('~/components/HomeTermine.vue'))
const Spielplan = loadHomeSection(() => import('~/components/Spielplan.vue'))
const PublicNews = loadHomeSection(() => import('~/components/PublicNews.vue'))
const HomeActions = loadHomeSection(() => import('~/components/HomeActions.vue'))
const HomeTrainingTeaser = loadHomeSection(() => import('~/components/HomeTrainingTeaser.vue'))
const HomeLinksTeaser = loadHomeSection(() => import('~/components/HomeLinksTeaser.vue'))
const HomeVereinsmeisterschaftenTeaser = loadHomeSection(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
const HomeSpielplanTeamWidget = loadHomeSection(() => import('~/components/HomeSpielplanTeamWidget.vue'))
const { data: config } = await useFetch('/api/config')
const { data: authStatus } = await useFetch('/api/auth/status')
@@ -344,6 +389,11 @@ function applyPersonalization(baseSections, settingsSections) {
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
const heroSection = computed(() => enabledSections.value.find(section => section.id === 'banner') || null)
const hasHeroSection = computed(() => !!heroSection.value)
const widgetSections = computed(() => enabledSections.value.filter(section => section.id !== 'banner'))
const featuredWidgetSection = computed(() => widgetSections.value[0] || null)
const remainingWidgetSections = computed(() => widgetSections.value.slice(1))
const componentMap = {
banner: Hero,
@@ -560,3 +610,26 @@ async function saveEditor() {
}
}
</script>
<style scoped>
.featured-widget-shell :deep(section),
.featured-widget-shell :deep(.bg-white),
.featured-widget-shell :deep(.bg-gray-50) {
--tw-bg-opacity: 0 !important;
background: transparent !important;
background-color: transparent !important;
}
.featured-widget-shell :deep([class*='bg-white']),
.featured-widget-shell :deep([class*='bg-gray-50']) {
--tw-bg-opacity: 0 !important;
background: transparent !important;
background-color: transparent !important;
}
.featured-widget-shell :deep(.py-16),
.featured-widget-shell :deep(.sm\:py-20) {
padding-top: 1.5rem !important;
padding-bottom: 2rem !important;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
<div>
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
QTTR-Werte
</h1>
<div class="w-24 h-1 bg-primary-600 mb-6" />
<p class="text-lg text-gray-700 max-w-3xl">
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>
</div>
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
<div class="flex flex-wrap items-center gap-4 justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900">
Harheimer TC Rangliste
</h2>
<p class="text-sm text-gray-600">
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
</p>
</div>
<div class="text-sm text-gray-500">
Aktualisiert: {{ formatDate(data?.importedAt) }}
</div>
</div>
<div v-if="pending" class="py-12 text-center text-gray-500">
Lade QTTR-Werte...
</div>
<div v-else-if="error" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Rang
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Spieler
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Verein
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
QTTR
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr
v-for="row in data?.rows || []"
:key="`${row.rank}-${row.playerNumber || row.playerName}`"
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
>
<td class="px-4 py-3 text-sm text-gray-600">
{{ row.rank ?? '' }}
</td>
<td class="px-4 py-3">
<div :class="['font-medium', getPlayerNameClass(row)]">
{{ row.playerName || 'Unbekannt' }}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700">
{{ row.clubName || 'Harheimer TC' }}
</td>
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
{{ row.currentQttr ?? '' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
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({
middleware: 'auth',
layout: 'default'
})
await authStore.checkAuth()
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
const currentUserName = computed(() => authStore.user?.name?.trim() || '')
function normalizeName(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/['`]/g, '')
}
function isMaleGender(value) {
const gender = normalizeName(value)
return gender.startsWith('m') || gender.includes('mann') || gender.includes('maenn')
}
function isFemaleGender(value) {
const gender = normalizeName(value)
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
}
function isOwnRow(playerName) {
const current = normalizeName(currentUserName.value)
if (!current) return false
return normalizeName(playerName) === current
}
function getPlayerNameClass(row) {
const minor = isMinor(row.birthdate)
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
if (minor && isFemaleGender(row.gender)) return 'text-pink-400'
if (isMaleGender(row.gender)) return 'text-blue-700'
if (isFemaleGender(row.gender)) return 'text-pink-800'
return 'text-gray-900'
}
function isMinor(birthdate) {
const date = parseBirthdate(birthdate)
if (!date) return false
const today = new Date()
let age = today.getFullYear() - date.getFullYear()
const monthDiff = today.getMonth() - date.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < date.getDate())) {
age -= 1
}
return age < 18
}
function parseBirthdate(value) {
const raw = String(value || '').trim()
if (!raw) return null
if (/^\d{4}$/.test(raw)) {
const parsed = new Date(Number(raw), 0, 1)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const parsed = new Date(raw)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function formatDate(value) {
if (!value) return 'unbekannt'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'unbekannt'
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)
}
useHead({
title: 'QTTR-Werte - Harheimer TC'
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -24,7 +24,7 @@ IFS=';' read -r -a BOXES <<< "$RECTS"
for box in "${BOXES[@]}"; do
IFS=',' read -r x y w h <<< "$box"
magick "$TMP" \
\( -size "${w}x${h}" xc:black -alpha set -channel a -evaluate set 70% +channel \) \
\( -size "${w}x${h}" xc:black -alpha off \) \
-geometry "+${x}+${y}" -composite "$TMP"
done

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
import { readdir, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, '..')
const args = process.argv.slice(2)
function readArg(flag, fallback = '') {
const idx = args.indexOf(flag)
if (idx === -1) return fallback
return args[idx + 1] || fallback
}
const inputDirArg = readArg('--input-dir', 'android-app/playstore-assets/anon')
const outputDirArg = readArg('--output-dir', 'android-app/playstore-assets/final')
const inputDir = path.resolve(rootDir, inputDirArg)
const outputDir = path.resolve(rootDir, outputDirArg)
const profiles = [
{ key: 'phone', width: 1080, height: 1920 },
{ key: 'tablet-7', width: 1200, height: 1920 },
{ key: 'tablet-10', width: 1600, height: 2560 },
]
function isImageFile(name) {
const lower = name.toLowerCase()
return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg')
}
async function processFile(fileName) {
const inputPath = path.join(inputDir, fileName)
const parsed = path.parse(fileName)
for (const profile of profiles) {
const profileDir = path.join(outputDir, profile.key)
await mkdir(profileDir, { recursive: true })
const outputPath = path.join(
profileDir,
`${parsed.name}-${profile.width}x${profile.height}.png`,
)
// Use contain to preserve all UI content and add solid bars only if needed.
await sharp(inputPath)
.resize(profile.width, profile.height, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 1 },
})
.png()
.toFile(outputPath)
console.log(`Created: ${path.relative(rootDir, outputPath)}`)
}
}
async function run() {
const files = await readdir(inputDir)
const images = files.filter(isImageFile)
if (images.length === 0) {
console.error(`No PNG/JPG files found in: ${path.relative(rootDir, inputDir)}`)
process.exitCode = 1
return
}
await mkdir(outputDir, { recursive: true })
for (const image of images) {
await processFile(image)
}
console.log(`Done. Output dir: ${path.relative(rootDir, outputDir)}`)
}
run().catch((error) => {
console.error('Failed to generate screenshot profiles:', error)
process.exitCode = 1
})

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
node "$ROOT_DIR/scripts/playstore-screenshot-sizes.mjs" "$@"

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