merge(01-03): module refactor to recipe.* conventions + drop js
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
phase: 01-project-infrastructure-module-wiring
|
||||||
|
plan: 03
|
||||||
|
subsystem: infra
|
||||||
|
tags: [gradle, kmp, convention-plugins, compose-multiplatform, ktor-server, android-library, explicitApi, ios-framework]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 01-project-infrastructure-module-wiring
|
||||||
|
provides: "Plan 01 extended libs.versions.toml (koin.android alias); Plan 02 created 5 recipe.* convention plugins in build-logic/"
|
||||||
|
provides:
|
||||||
|
- "composeApp/build.gradle.kts reduced from 114 to 28 lines; role-declaration plugin block applying recipe.kotlin.multiplatform + recipe.compose.multiplatform + recipe.android.application + recipe.quality"
|
||||||
|
- "shared/build.gradle.kts reduced from 55 to 36 lines; applies recipe.kotlin.multiplatform + recipe.quality + androidLibrary; enables explicitApi(); overrides iOS framework baseName to 'Shared'"
|
||||||
|
- "server/build.gradle.kts reduced from 23 to 18 lines; applies recipe.jvm.server + recipe.quality; retains only module-specific mainClass + projects.shared dep"
|
||||||
|
- "js target fully removed: shared/src/jsMain/ directory deleted (D-01)"
|
||||||
|
- "iosX64 remains absent across all modules (D-02)"
|
||||||
|
- "INFRA-02 structural payoff visible: adding a new KMP module henceforth requires only plugins { id('recipe.kotlin.multiplatform') } + sourceSet declarations"
|
||||||
|
- "INFRA-06 structural prerequisite: shared/ no longer applies recipe.compose.multiplatform, so Compose cannot leak transitively"
|
||||||
|
affects: [02-auth, 03-households, 04-sync-skeleton, 05-recipe-catalog, 10-ui-chrome]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: [] # Plan 03 is pure refactor — all libraries/tools already added in Plans 01/02
|
||||||
|
patterns:
|
||||||
|
- "Role-declaration plugin blocks (D-06): module build.gradle.kts plugins {} lists only recipe.* IDs + module-specific aliases (e.g. androidLibrary on shared/)"
|
||||||
|
- "Per-module override pattern: shared/ overrides framework baseName by targeting KotlinNativeTarget + Framework directly in the module, not from the convention plugin (D-07 / PITFALL #10)"
|
||||||
|
- "Module-specific dep retention: jvmMain compose.desktop.currentOs + kotlinx.coroutinesSwing stay in composeApp; android debug-only libs.compose.uiTooling stays as debugImplementation"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- "composeApp/build.gradle.kts — rewritten: 4 recipe.* plugin IDs + 3-source-set dep block + 1 debug tooling line"
|
||||||
|
- "shared/build.gradle.kts — rewritten: 3 plugins + explicitApi() + Framework baseName override + android {} block retained"
|
||||||
|
- "server/build.gradle.kts — rewritten: 2 recipe.* plugin IDs + application {} + projects.shared dep"
|
||||||
|
- "shared/src/jsMain/kotlin/dev/ulfrx/recipe/Platform.js.kt — DELETED (D-01 drops js target)"
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Keep android { namespace = 'dev.ulfrx.recipe.shared' } block applied in Phase 1 per Open Question #1 (com.android.library retained; future recipe.android.library convention plugin deferred)"
|
||||||
|
- "libs.versions.* typed accessor used directly in module build.gradle.kts (not libs.findVersion) — PITFALL #1 only applies to precompiled plugin scripts, not module scripts"
|
||||||
|
- "libs.koin.android added to composeApp androidMain (not commonMain) — Koin's androidContext(...) lives in the android-specific artifact; commonMain stays platform-neutral"
|
||||||
|
- "Framework baseName override placed in the module, not hoisted into recipe.kotlin.multiplatform — shared/ is the only module needing 'Shared' (composeApp keeps convention default 'ComposeApp'), so keeping it local avoids a plugin parameter"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Plugin role declaration: each module build.gradle.kts opens with id('recipe.<role>') IDs — reading the plugins block tells you what the module IS, not how it's configured"
|
||||||
|
- "Zero version literals in module build files: dependencies always go through libs.* aliases; only project coordinate 'version = 1.0.0' (unindented) is exempted by tools/verify-no-version-literals.sh"
|
||||||
|
- "Per-module framework basename: KotlinNativeTarget.binaries.withType<Framework>().configureEach { baseName = … } pattern is the canonical override point"
|
||||||
|
|
||||||
|
requirements-completed: [INFRA-02, INFRA-06]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: ~8min
|
||||||
|
completed: 2026-04-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 01 Plan 03: Module Build Scripts Wiring Summary
|
||||||
|
|
||||||
|
**Rewrote all three module build.gradle.kts files as role declarations applying recipe.* convention plugins; dropped the js target (shared/src/jsMain/ deleted); enabled explicitApi() + 'Shared' framework basename on shared/.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~8 min
|
||||||
|
- **Started:** 2026-04-24T16:14:27Z
|
||||||
|
- **Completed:** 2026-04-24T16:22:17Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 3 (composeApp, shared, server build.gradle.kts) + 1 deleted (Platform.js.kt)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- **composeApp/build.gradle.kts:** 114 → 28 lines (-75%). Structural blocks (androidTarget, iosArm64/iosSimulatorArm64, jvm, js, wasmJs, android { }, compose.desktop { nativeDistributions }) all removed and inherited from convention plugins. Only 3 source-set dep blocks + 1 debug tooling line remain.
|
||||||
|
- **shared/build.gradle.kts:** 55 → 36 lines (-35%). Structural target blocks moved to recipe.kotlin.multiplatform; explicitApi() + KotlinNativeTarget/Framework baseName = "Shared" override added (D-07 / D-12 / PITFALL #10); android {} block kept per Open Question #1.
|
||||||
|
- **server/build.gradle.kts:** 23 → 18 lines (-22%). Dependency declarations (logback, ktor-serverCore/Netty/TestHost, kotlin-testJunit) fully relocated into recipe.jvm.server; only module coordinates + mainClass + projects.shared remain.
|
||||||
|
- **js target eliminated:** `shared/src/jsMain/kotlin/dev/ulfrx/recipe/Platform.js.kt` deleted (D-01). No `js { browser() }` blocks remain in any module build file.
|
||||||
|
- **INFRA-02 payoff visible:** the plugin block in each module now reads as a role declaration (D-06). A future KMP module just needs `plugins { id("recipe.kotlin.multiplatform") }` + sourceSet declarations — no target/SDK copy-pasting.
|
||||||
|
- **INFRA-06 structural prerequisite delivered:** recipe.compose.multiplatform is applied ONLY to composeApp/, never to shared/, so Compose deps cannot leak transitively into the shared module's classpath.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically (with `--no-verify` per parallel-executor protocol):
|
||||||
|
|
||||||
|
1. **Task 1: Rewrite composeApp + shared build files, delete shared/src/jsMain/** — `d76dcea` (refactor)
|
||||||
|
2. **Task 2: Rewrite server build file** — `d316a48` (refactor)
|
||||||
|
|
||||||
|
_Note: no test/feat/refactor trio — the plan is marked `type=execute`, not `type=tdd`, and all work is build-script configuration (no production code to test)._
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `composeApp/build.gradle.kts` — rewritten: 4 recipe.* plugin IDs, androidMain/commonMain/jvmMain dep blocks, debugImplementation line
|
||||||
|
- `shared/build.gradle.kts` — rewritten: 3 plugins (recipe.kotlin.multiplatform + recipe.quality + androidLibrary), explicitApi(), Framework baseName = "Shared" override, android {} retained
|
||||||
|
- `server/build.gradle.kts` — rewritten: 2 recipe.* plugin IDs, application { mainClass + JVM args }, implementation(projects.shared)
|
||||||
|
- `shared/src/jsMain/kotlin/dev/ulfrx/recipe/Platform.js.kt` — DELETED (D-01 — js target dropped)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- **`libs.versions.*` typed accessor used in module build.gradle.kts rather than `libs.findVersion(...)`** — PITFALL #1 restricts the typed accessor to precompiled plugins; module scripts have full access, so the typed form (`libs.versions.android.compileSdk.get().toInt()`) is correct and preserved from the prior version of `shared/build.gradle.kts`.
|
||||||
|
- **Framework baseName override kept local to shared/** — only shared/ needs `"Shared"`; composeApp/ keeps the convention-plugin default `"ComposeApp"`. Hoisting the override into `recipe.kotlin.multiplatform` would require a plugin parameter for a single consumer — not worth the indirection.
|
||||||
|
- **`android { }` block retained on shared/** — Open Question #1 in RESEARCH.md defers "do we actually need com.android.library on shared/?" to a future `recipe.android.library` convention plugin. Phase 1 keeps the block applied; a future plan may remove it.
|
||||||
|
- **`libs.koin.android` placed in composeApp androidMain, not commonMain** — the `androidContext(...)` helper used by Plan 04's MainApplication lives in koin-android (JVM/Android artifact). commonMain keeps only platform-neutral deps.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
One minor note (not a deviation, not a failure): `shared/build.gradle.kts` ended at 36 lines vs. the plan's informal `~35-line` target. The single-line delta is the non-negotiable explanatory comment above the `KotlinNativeTarget`/`Framework` block. The plan's `acceptance_criteria` does not set a line cap on `shared/` (only `composeApp/ ≤ 30` which passes at 28 and `server/ ≤ 20` which passes at 18), so all criteria are green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 0
|
||||||
|
**Impact on plan:** Plan executed as specified. All `<automated>` verify blocks pass (grep chain for each module + `tools/verify-no-version-literals.sh` + `tools/verify-shared-pure.sh`).
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - pure build-script refactor; no external service configuration required.
|
||||||
|
|
||||||
|
## Parallel-Wave Coordination Notes
|
||||||
|
|
||||||
|
This plan ran as a parallel executor in Wave 2 alongside Plans 02, 04, 05, 06. Per the wave-2 coordination note:
|
||||||
|
|
||||||
|
- **No `./gradlew` commands executed in this plan.** The convention plugins referenced by `id("recipe.kotlin.multiplatform")` etc. are created by Plan 02 in a separate worktree; this worktree does NOT see those files. Gradle plugin resolution will succeed after all Wave 2 worktrees merge back to master and Plan 07 runs the full green-build gate.
|
||||||
|
- **Verification is entirely grep-based**, matching the plan's `<automated>` specification. No runtime build invocation needed at this stage.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
Ready for downstream plans in Phase 01:
|
||||||
|
- **Plan 04 (compose app skeleton)** can now rely on composeApp's `recipe.compose.multiplatform` application — Compose deps (compose.runtime/foundation/material3/ui/components.resources/lifecycle.*compose) flow in via the convention.
|
||||||
|
- **Plan 05 (server skeleton)** can rely on server's `recipe.jvm.server` — Ktor server + Flyway + Postgres + serialization flow in via the convention; module only needs to declare `mainClass` and `projects.shared`.
|
||||||
|
- **Plan 07 (invariant gate)** will validate the wired build via `./gradlew build` after all Wave 2 worktrees merge back.
|
||||||
|
|
||||||
|
Downstream phases (Phase 02+ auth, Phase 05 recipe catalog, etc.) inherit a strict boundary: `shared/commonMain` enforces `explicitApi()` and carries no Compose / Ktor / SQLDelight deps. Any attempt to add forbidden imports will be caught by `tools/verify-shared-pure.sh`.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
**Files verified:**
|
||||||
|
- FOUND: `composeApp/build.gradle.kts` (28 lines, 4 recipe.* plugin IDs present, no androidTarget/iosArm64/js/nativeDistributions/^android{, libs.koin.android present)
|
||||||
|
- FOUND: `shared/build.gradle.kts` (36 lines, 3 plugins present, explicitApi() present, `baseName = "Shared"` present, no js {, android {} retained)
|
||||||
|
- FOUND: `server/build.gradle.kts` (18 lines, 2 recipe.* plugin IDs present, mainClass present, projects.shared present, no legacy aliases or deps)
|
||||||
|
- MISSING (intentional): `shared/src/jsMain/` directory no longer exists
|
||||||
|
|
||||||
|
**Commits verified:**
|
||||||
|
- FOUND: `d76dcea` — refactor(01-03): apply recipe.* conventions to composeApp + shared, drop js
|
||||||
|
- FOUND: `d316a48` — refactor(01-03): apply recipe.jvm.server + recipe.quality to server module
|
||||||
|
|
||||||
|
**Verify scripts:**
|
||||||
|
- `tools/verify-no-version-literals.sh` → exit 0 (OK: no version literals outside catalog)
|
||||||
|
- `tools/verify-shared-pure.sh` → exit 0 (OK: shared/commonMain is pure)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 01-project-infrastructure-module-wiring*
|
||||||
|
*Plan: 03*
|
||||||
|
*Completed: 2026-04-24*
|
||||||
@@ -1,68 +1,21 @@
|
|||||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
id("recipe.kotlin.multiplatform")
|
||||||
alias(libs.plugins.androidApplication)
|
id("recipe.compose.multiplatform")
|
||||||
alias(libs.plugins.composeMultiplatform)
|
id("recipe.android.application")
|
||||||
alias(libs.plugins.composeCompiler)
|
id("recipe.quality")
|
||||||
alias(libs.plugins.composeHotReload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listOf(
|
|
||||||
iosArm64(),
|
|
||||||
iosSimulatorArm64()
|
|
||||||
).forEach { iosTarget ->
|
|
||||||
iosTarget.binaries.framework {
|
|
||||||
baseName = "ComposeApp"
|
|
||||||
isStatic = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jvm {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
js {
|
|
||||||
browser()
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.koin.android)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
|
||||||
implementation(libs.compose.foundation)
|
|
||||||
implementation(libs.compose.material3)
|
|
||||||
implementation(libs.compose.ui)
|
|
||||||
implementation(libs.compose.components.resources)
|
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
|
||||||
implementation(projects.shared)
|
implementation(projects.shared)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.kotlinx.coroutinesSwing)
|
implementation(libs.kotlinx.coroutinesSwing)
|
||||||
@@ -70,45 +23,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "dev.ulfrx.recipe"
|
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "dev.ulfrx.recipe"
|
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0"
|
|
||||||
}
|
|
||||||
packaging {
|
|
||||||
resources {
|
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
getByName("release") {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
debugImplementation(libs.compose.uiTooling)
|
debugImplementation(libs.compose.uiTooling)
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
|
||||||
application {
|
|
||||||
mainClass = "dev.ulfrx.recipe.MainKt"
|
|
||||||
|
|
||||||
nativeDistributions {
|
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
|
||||||
packageName = "dev.ulfrx.recipe"
|
|
||||||
packageVersion = "1.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinJvm)
|
id("recipe.jvm.server")
|
||||||
alias(libs.plugins.ktor)
|
id("recipe.quality")
|
||||||
application
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "dev.ulfrx.recipe"
|
group = "dev.ulfrx.recipe"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
|
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
|
||||||
|
|
||||||
@@ -15,9 +15,4 @@ application {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.shared)
|
implementation(projects.shared)
|
||||||
implementation(libs.logback)
|
|
||||||
implementation(libs.ktor.serverCore)
|
|
||||||
implementation(libs.ktor.serverNetty)
|
|
||||||
testImplementation(libs.ktor.serverTestHost)
|
|
||||||
testImplementation(libs.kotlin.testJunit)
|
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,24 @@
|
|||||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
id("recipe.kotlin.multiplatform")
|
||||||
|
id("recipe.quality")
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget {
|
explicitApi()
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
// Override framework baseName: shared exposes "Shared.framework" to Swift, while
|
||||||
|
// composeApp's convention-plugin default is "ComposeApp.framework". (D-07 / PITFALL #10)
|
||||||
|
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
|
||||||
|
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().configureEach {
|
||||||
|
baseName = "Shared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
iosArm64()
|
|
||||||
iosSimulatorArm64()
|
|
||||||
|
|
||||||
jvm {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
js {
|
|
||||||
browser()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// put your Multiplatform dependencies here
|
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
|
||||||
}
|
// D-19 / INFRA-06: Do NOT add Ktor, Compose, or SQLDelight deps here — EVER.
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package dev.ulfrx.recipe
|
|
||||||
|
|
||||||
class JsPlatform : Platform {
|
|
||||||
override val name: String = "Web with Kotlin/JS"
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun getPlatform(): Platform = JsPlatform()
|
|
||||||
Reference in New Issue
Block a user