Add a Compose HTML (DOM) web app and split Compose UI out of :shared
webHtmlApp is a new web build using Compose HTML instead of Compose Multiplatform UI's Skia/canvas renderer - real <select>/<input>/<button> elements, an anchored popup menu for reordering/removing currencies (Compose HTML has no built-in DropdownMenu), and locale-independent English strings (moko-resources' Compose integration pulls in compose.foundation/ui, which this module exists to avoid). Realizing the bundle-size win needed splitting sharedUi out of shared: the Compose Multiplatform Gradle plugin bundles Skiko's several-MB web runtime into any js/wasmJs target whose resolved dependencies contain org.jetbrains.compose.ui:ui anywhere, and shared previously declared it directly for the other (Skia-based) web app's UI. shared is now pure business/data logic with no Compose dependency; sharedUi holds App()/ PriceScreen() for androidApp, desktopApp, and webApp to render. PriceViewModel's ViewModel supertype and CurrencyConverter's BigDecimal params moved from implementation() to api() in shared, since they're part of its public surface that webHtmlApp now touches directly. Also drops the @js-joda/timezone dependency from both web builds: the app only ever calls TimeZone.currentSystemDefault(), which resolves to a synthetic zone backed by the native Date offset on Kotlin/JS and never touches the tz database - confirmed empirically on both js and wasmJs targets. Cuts webApp's js bundle by ~950 KB and webHtmlApp's by ~800 KB. currencyFlagEmoji() gained an explicit supportsFlagEmoji parameter (defaulting to the existing expect/actual, false on web) so webHtmlApp can opt in - Compose HTML renders real DOM text, so flag emoji work fine there even though they don't on Compose Multiplatform's canvas. website/serve-local.sh now builds and serves webHtmlApp instead of webApp's wasmJs build, so it no longer mirrors what's actually deployed to production; docs updated to reflect the new module layout and that divergence, and the Pages workflow's path trigger now includes sharedUi/** so UI changes there still redeploy the site. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- "website/**"
|
||||
- "shared/**"
|
||||
- "sharedUi/**"
|
||||
- "webApp/**"
|
||||
- "gradle/**"
|
||||
- "gradlew"
|
||||
|
||||
@@ -24,10 +24,28 @@ Kotlin) implementation — `main` is now this Kotlin Multiplatform project.
|
||||
|
||||
## Module layout
|
||||
|
||||
- `shared/` — the actual app: UI, view models, data sources, domain logic.
|
||||
Almost everything happens here.
|
||||
- `shared/` — view models, data sources, domain logic. No Compose UI
|
||||
toolkit dependency (deliberately - see `sharedUi/` below). Almost all
|
||||
business logic happens here.
|
||||
- `sharedUi/` — the Skia-based Compose Multiplatform UI (`App()`,
|
||||
`ui/PriceScreen.kt`) that `androidApp`, `desktopApp`, and `webApp`
|
||||
render. Split out from `shared` so a js/wasmJs consumer that doesn't
|
||||
want Compose UI's several-MB Skia web runtime (see `webHtmlApp/`) can
|
||||
depend on `shared` alone without pulling it in transitively - the
|
||||
Compose Multiplatform Gradle plugin bundles that runtime into *any*
|
||||
js/wasmJs target whose resolved dependencies contain
|
||||
`org.jetbrains.compose.ui:ui` anywhere.
|
||||
- `androidApp/`, `desktopApp/`, `webApp/` — thin launcher shells around
|
||||
`shared`'s Compose UI. Rarely need changes.
|
||||
`sharedUi`'s Compose UI. Rarely need changes.
|
||||
- `webHtmlApp/` — an alternative web build rendering to real DOM via
|
||||
Compose HTML instead of Compose Multiplatform UI's canvas/Skia. Depends
|
||||
on `shared` directly, not `sharedUi`: Compose HTML and Compose
|
||||
Multiplatform UI are different, incompatible composable sets, so its
|
||||
screens (`HtmlApp.kt`, `HtmlCurrencyPicker.kt`) are a separate
|
||||
hand-written port of `sharedUi/ui/PriceScreen.kt` rather than a shared
|
||||
one. Not what's deployed to production (`webApp`'s wasmJs build is);
|
||||
exists for comparing the two approaches and via
|
||||
`website/serve-local.sh`.
|
||||
- `iosApp/` — a native SwiftUI app (shared across iOS and macOS via
|
||||
`#if os(macOS)`), **not** Compose. It talks to `shared` through a
|
||||
hand-written bridge (see "Core architecture" below), not by rendering
|
||||
@@ -53,8 +71,10 @@ unused KMP-wizard scaffolding, not load-bearing.
|
||||
(`ConverterUiState.xyz()` extension functions) shared between Compose and
|
||||
the iOS bridge. **Read the localization note below before adding
|
||||
anything here that produces user-facing text.**
|
||||
- `ui/PriceScreen.kt` — the actual Compose UI. Used as-is by Android,
|
||||
Desktop, and Web (same composable, three renderers).
|
||||
- `sharedUi/src/commonMain/.../ui/PriceScreen.kt` — the actual Compose UI.
|
||||
Used as-is by Android, Desktop, and `webApp` (same composable, three
|
||||
renderers). `webHtmlApp` has its own separate Compose HTML port of this
|
||||
screen instead (see "Module layout" above).
|
||||
- iOS/macOS **doesn't** use Compose. The chain is:
|
||||
`PriceViewModel` (Kotlin) → `IosPriceViewModel`/`IosConverterState`
|
||||
(`shared/src/appleMain/.../IosPriceViewModel.kt`, a flattened Map-free
|
||||
@@ -92,6 +112,13 @@ the pattern to copy: it returns a locale-formatted `String?` (using
|
||||
locale-aware formatting via platform APIs, not translated text), and each
|
||||
UI layer wraps it with its own localized "Updated %1$s" string.
|
||||
|
||||
`webHtmlApp` is the one UI layer that deliberately breaks this pattern: its
|
||||
`Strings.kt` hardcodes English rather than calling `stringResource()`,
|
||||
because moko-resources' Compose integration (`moko-resourcesCompose`) pulls
|
||||
in `compose.foundation`/`compose.ui` transitively - exactly the dependency
|
||||
`webHtmlApp` exists to avoid (see "Module layout" above). Not a template to
|
||||
follow elsewhere; a one-off tradeoff specific to that module.
|
||||
|
||||
## Locale-aware formatting
|
||||
|
||||
`domain/NumberFormat.kt` (digit grouping, decimal separator) and
|
||||
@@ -164,14 +191,17 @@ site (nav, hero, download section) via `/app/`.
|
||||
|
||||
- `website/app/` is git-ignored — it's build output, generated fresh by CI
|
||||
(and locally by the script below), never committed.
|
||||
- To reproduce the production layout locally (site + web app together,
|
||||
`/app/` links working): `./website/serve-local.sh [port]`. It builds the
|
||||
wasmJs distribution, copies it into `website/app/`, and serves
|
||||
`website/` with `python3 -m http.server`.
|
||||
- To try the site + web app together locally (`/app/` links working):
|
||||
`./website/serve-local.sh [port]`. It builds `webHtmlApp`'s Compose
|
||||
HTML (DOM) distribution, copies it into `website/app/`, and serves
|
||||
`website/` with `python3 -m http.server` — note this no longer matches
|
||||
what the Pages workflow actually deploys (still the Skia `webApp`
|
||||
wasmJs build), so it's for trying the DOM build, not reproducing
|
||||
production.
|
||||
- If you change the Pages workflow, remember `paths:` in the trigger
|
||||
includes `webApp/**`/`shared/**`/Gradle files, not just `website/**` —
|
||||
a shared-code change that affects the web app should also redeploy the
|
||||
site.
|
||||
includes `webApp/**`/`shared/**`/`sharedUi/**`/Gradle files, not just
|
||||
`website/**` — a shared-code change that affects the web app should
|
||||
also redeploy the site.
|
||||
|
||||
## Testing and verification
|
||||
|
||||
@@ -195,6 +225,10 @@ site (nav, hero, download section) via `/app/`.
|
||||
`--headless=new --disable-gpu-sandbox --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader --ignore-gpu-blocklist`.
|
||||
This is also the most practical way to verify a Compose UI change at
|
||||
all, since there's no headless Android/Desktop runner set up here.
|
||||
- `webHtmlApp` is the exception to the above: it renders real DOM, so
|
||||
plain `--headless=new --dump-dom` (no WebGL/GPU flags needed) shows
|
||||
actual inspectable HTML. Build/serve it with
|
||||
`./gradlew :webHtmlApp:jsBrowserDistribution` and a static file server.
|
||||
- Interactive verification of the native macOS build via AppleScript/System
|
||||
Events GUI scripting works but is genuinely flaky — stale processes can
|
||||
linger across launches, the accessibility tree doesn't always reflect
|
||||
|
||||
@@ -71,13 +71,15 @@ options:
|
||||
- Desktop app:
|
||||
- Hot reload: `./gradlew :desktopApp:hotRun --auto`
|
||||
- Standard run: `./gradlew :desktopApp:run`
|
||||
- Web app:
|
||||
- Web app (Compose Multiplatform UI, Skia-rendered - what's deployed to production):
|
||||
- Wasm target (faster, modern browsers): `./gradlew :webApp:wasmJsBrowserDevelopmentRun`
|
||||
- JS target (slower, supports older browsers): `./gradlew :webApp:jsBrowserDevelopmentRun`
|
||||
- Web app, DOM alternative (Compose HTML UI, renders real DOM instead of Skia/canvas - not what's
|
||||
deployed to production): `./gradlew :webHtmlApp:jsBrowserDevelopmentRun`
|
||||
- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there.
|
||||
- [Website](./website) (with the web app built and served at `/app/`, matching production):
|
||||
`./website/serve-local.sh` (serves at http://localhost:8000 by default; pass a port number to
|
||||
override)
|
||||
- [Website](./website) with the web app built and served at `/app/` (the `webHtmlApp` DOM build,
|
||||
not the one production actually deploys): `./website/serve-local.sh` (serves at
|
||||
http://localhost:8000 by default; pass a port number to override)
|
||||
|
||||
### Running tests
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ kotlin {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(project(":sharedUi"))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(project(":sharedUi"))
|
||||
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
|
||||
@@ -34,6 +34,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||
androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
@@ -41,6 +42,7 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||
compose-html-core = { module = "org.jetbrains.compose.html:html-core", version.ref = "composeMultiplatform" }
|
||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" }
|
||||
moko-resourcesCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" }
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
||||
|
||||
"@js-joda/timezone@2.25.1":
|
||||
version "2.25.1"
|
||||
resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.25.1.tgz#4628f18d51af1b197a410834d344418f48a8a264"
|
||||
integrity sha512-s79ts8bXrWqM9dIBKc0AdgGuAUFpu9gmzYhOCPHJlks/Sf7FSbJHRauWlFYUwjSTZevimqthEvJycrwrVz5m4g==
|
||||
|
||||
"@messageformat/core@3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.1.0.tgz#d4d2f5c3555228a6b5980b122a02b53dfc6458bd"
|
||||
|
||||
@@ -63,11 +63,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
||||
|
||||
"@js-joda/timezone@2.25.1":
|
||||
version "2.25.1"
|
||||
resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.25.1.tgz#4628f18d51af1b197a410834d344418f48a8a264"
|
||||
integrity sha512-s79ts8bXrWqM9dIBKc0AdgGuAUFpu9gmzYhOCPHJlks/Sf7FSbJHRauWlFYUwjSTZevimqthEvJycrwrVz5m4g==
|
||||
|
||||
"@jsonjoy.com/base64@17.67.0":
|
||||
version "17.67.0"
|
||||
resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.67.0.tgz#7eeda3cb41138d77a90408fd2e42b2aba10576d7"
|
||||
|
||||
+3
-1
@@ -30,4 +30,6 @@ dependencyResolutionManagement {
|
||||
include(":androidApp")
|
||||
include(":desktopApp")
|
||||
include(":shared")
|
||||
include(":webApp")
|
||||
include(":sharedUi")
|
||||
include(":webApp")
|
||||
include(":webHtmlApp")
|
||||
+6
-18
@@ -4,8 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidMultiplatformLibrary)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.mokoResources)
|
||||
alias(libs.plugins.sqldelight)
|
||||
@@ -62,28 +60,22 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.compose.uiTooling)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.sqldelight.androidDriver)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
api(libs.moko.resources)
|
||||
api(libs.moko.resourcesCompose)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
// PriceViewModel's own supertype and CurrencyConverter's BigDecimal parameters are
|
||||
// part of :shared's public API, so consumers that touch those types directly (e.g.
|
||||
// webHtmlApp's Compose HTML screens) need them on their own compile classpath too -
|
||||
// api() (not implementation()) is what makes Gradle expose them transitively.
|
||||
api(libs.androidx.lifecycle.viewmodel)
|
||||
api(libs.bignum)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.contentNegotiation)
|
||||
implementation(libs.ktor.serialization.kotlinxJson)
|
||||
implementation(libs.bignum)
|
||||
implementation(libs.sqldelight.runtime)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
@@ -112,10 +104,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidRuntimeClasspath(libs.compose.uiTooling)
|
||||
}
|
||||
|
||||
multiplatformResources {
|
||||
resourcesPackage.set("xyz.tyiu.satsprice.shared")
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package xyz.tyiu.satsprice.ui
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// PriceScreen.kt is unused on iOS (native SwiftUI is used instead), but this module still
|
||||
// needs an actual to satisfy the expect declaration for this target.
|
||||
actual val screenHorizontalPadding: Dp = 16.dp
|
||||
@@ -36,11 +36,14 @@ internal fun issuingCountryCodes(code: String): List<String> {
|
||||
* A country flag emoji for [code], or null when there's no flag to show — either because [code]
|
||||
* isn't tied to any country, because it's shared by more than [MAX_FLAGS_PER_CURRENCY] countries
|
||||
* (too visually noisy to show side by side), or because [supportsFlagEmoji] says the platform
|
||||
* can't render one anyway (Compose for Web's canvas-based text renderer has no color-emoji font
|
||||
* to fall back to, so a flag's regional-indicator codepoints draw as empty boxes there).
|
||||
* can't render one anyway. [supportsFlagEmoji] defaults to the expect/actual of the same name,
|
||||
* which is false for Compose Multiplatform's canvas-based Skia renderer on web (no bundled or
|
||||
* system color-emoji font to fall back to, so a flag's regional-indicator codepoints draw as
|
||||
* empty boxes there) - callers that render through a real DOM text node instead (e.g. Compose
|
||||
* HTML) can pass `true` explicitly, since the browser's own font stack handles it fine.
|
||||
*/
|
||||
fun currencyFlagEmoji(code: String): String? {
|
||||
if (!supportsFlagEmoji()) return null
|
||||
fun currencyFlagEmoji(code: String, supportsFlagEmoji: Boolean = supportsFlagEmoji()): String? {
|
||||
if (!supportsFlagEmoji) return null
|
||||
val regionCodes = issuingCountryCodes(code)
|
||||
if (regionCodes.isEmpty() || regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null
|
||||
return regionCodes.joinToString(" ") { regionFlagEmoji(it) }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidMultiplatformLibrary)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
// The Skia-based Compose Multiplatform UI (App()/PriceScreen()) that androidApp, desktopApp,
|
||||
// and webApp render. Split out of :shared so :shared itself stays free of compose.ui - the
|
||||
// Compose Multiplatform Gradle plugin bundles the several-MB Skiko web runtime into *any*
|
||||
// js/wasmJs target whose resolved dependency graph contains org.jetbrains.compose.ui:ui
|
||||
// anywhere, and webHtmlApp's DOM-only build needs to avoid that.
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
js {
|
||||
browser()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "xyz.tyiu.satsprice.sharedui"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.compose.uiTooling)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
api(project(":shared"))
|
||||
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.moko.resourcesCompose)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidRuntimeClasspath(libs.compose.uiTooling)
|
||||
}
|
||||
@@ -21,11 +21,9 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(project(":sharedUi"))
|
||||
|
||||
implementation(libs.compose.ui)
|
||||
|
||||
implementation(npm("@js-joda/timezone", "2.25.1"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,9 @@ package xyz.tyiu.satsprice
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
|
||||
@OptIn(ExperimentalWasmJsInterop::class)
|
||||
@JsModule("@js-joda/timezone")
|
||||
external object JsJodaTimeZoneModule
|
||||
|
||||
private val jsJodaTz = JsJodaTimeZoneModule
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
ComposeViewport {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
// Compose HTML (org.jetbrains.compose.html:html-core) only publishes js and jvm target
|
||||
// variants as of Compose Multiplatform 1.11.1 - no wasmJs artifact exists yet, unlike the
|
||||
// Skia-based Compose Multiplatform UI toolkit webApp uses (which targets both). So this
|
||||
// comparison module is js-only for now.
|
||||
kotlin {
|
||||
js {
|
||||
browser()
|
||||
binaries.executable()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.html.core)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package xyz.tyiu.satsprice
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.jetbrains.compose.web.attributes.InputType
|
||||
import org.jetbrains.compose.web.dom.Button
|
||||
import org.jetbrains.compose.web.dom.Div
|
||||
import org.jetbrains.compose.web.dom.H1
|
||||
import org.jetbrains.compose.web.dom.Input
|
||||
import org.jetbrains.compose.web.dom.Option
|
||||
import org.jetbrains.compose.web.dom.Select
|
||||
import org.jetbrains.compose.web.dom.Span
|
||||
import org.jetbrains.compose.web.dom.Text
|
||||
import xyz.tyiu.satsprice.domain.CurrencyConverter
|
||||
import xyz.tyiu.satsprice.domain.formatAmount
|
||||
import xyz.tyiu.satsprice.domain.groupDigits
|
||||
import xyz.tyiu.satsprice.ui.PriceViewModel
|
||||
import xyz.tyiu.satsprice.ui.defaultCurrencyRate
|
||||
import xyz.tyiu.satsprice.ui.exceedsMaxSupply
|
||||
import xyz.tyiu.satsprice.ui.isPriced
|
||||
import xyz.tyiu.satsprice.ui.lastUpdatedDateTime
|
||||
import xyz.tyiu.satsprice.ui.oneCurrencyToSats
|
||||
|
||||
@Composable
|
||||
fun HtmlApp() {
|
||||
val viewModel = remember { PriceViewModel() }
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showCurrencyPicker) {
|
||||
HtmlCurrencyPicker(
|
||||
state = state,
|
||||
onToggle = viewModel::onFiatCurrencyToggled,
|
||||
onReset = viewModel::onSelectedCurrenciesReset,
|
||||
onDone = { showCurrencyPicker = false },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Div({ classes("screen") }) {
|
||||
H1({ classes("app-title") }) { Text("SatsPrice") }
|
||||
|
||||
// Price source
|
||||
Div({ classes("card") }) {
|
||||
Div({ classes("row") }) {
|
||||
Span({ classes("field-inline-label") }) { Text(Strings.priceSource) }
|
||||
Select(attrs = {
|
||||
onChange { event -> viewModel.onSourceSelected(event.target.value) }
|
||||
}) {
|
||||
viewModel.availableSources.forEach { name ->
|
||||
Option(value = name, attrs = { if (name == state.sourceName) attr("selected", "") }) { Text(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Div({ classes("row") }) {
|
||||
LabeledInput(
|
||||
label = Strings.btcToCurrency(state.defaultCurrencyCode),
|
||||
value = if (state.isManualSource) state.manualRateInput else state.defaultCurrencyRate(),
|
||||
readOnly = !state.isManualSource,
|
||||
onValueChange = viewModel::onManualRateChanged,
|
||||
modifierClass = "grow",
|
||||
)
|
||||
if (!state.isManualSource) {
|
||||
if (state.isLoading) {
|
||||
Span({ classes("spinner") })
|
||||
} else {
|
||||
Button(attrs = {
|
||||
classes("icon-button")
|
||||
attr("aria-label", Strings.refresh)
|
||||
attr("title", Strings.refresh)
|
||||
onClick { viewModel.refresh() }
|
||||
}) { Text("↻") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isManualSource) {
|
||||
LabeledInput(
|
||||
label = Strings.currencyToSats(state.defaultCurrencyCode),
|
||||
value = state.manualSatsPerCurrencyInput,
|
||||
onValueChange = viewModel::onManualSatsPerCurrencyChanged,
|
||||
)
|
||||
} else {
|
||||
LabeledInput(
|
||||
label = Strings.currencyToSats(state.defaultCurrencyCode),
|
||||
value = state.oneCurrencyToSats(),
|
||||
readOnly = true,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
val statusLine = if (state.isManualSource) {
|
||||
null
|
||||
} else {
|
||||
state.lastUpdatedDateTime()?.let { Strings.updatedStatus(it) } ?: Strings.loadingRatesStatus
|
||||
}
|
||||
statusLine?.let { Div({ classes("status") }) { Text(it) } }
|
||||
}
|
||||
|
||||
state.errorMessage?.let { message ->
|
||||
Div({ classes("card", "error-banner") }) {
|
||||
Span { Text(message) }
|
||||
Button(attrs = { onClick { viewModel.refresh() } }) { Text(Strings.retry) }
|
||||
}
|
||||
}
|
||||
|
||||
val exceedsMaxSupply = state.exceedsMaxSupply()
|
||||
|
||||
// Bitcoin
|
||||
Div({ classes("card") }) {
|
||||
Div({ classes("section-title") }) { Text(Strings.bitcoinSectionTitle) }
|
||||
LabeledInput(
|
||||
label = Strings.satsLabel,
|
||||
value = state.satsAmount,
|
||||
isError = exceedsMaxSupply,
|
||||
onValueChange = viewModel::onSatsAmountChanged,
|
||||
)
|
||||
LabeledInput(
|
||||
label = Strings.btcLabel,
|
||||
value = state.btcAmount,
|
||||
isError = exceedsMaxSupply,
|
||||
onValueChange = viewModel::onBtcAmountChanged,
|
||||
)
|
||||
if (exceedsMaxSupply) {
|
||||
val maxSupplyText = groupDigits(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) + " BTC"
|
||||
Div({ classes("warning") }) { Text(Strings.exceedsMaxSupply(maxSupplyText)) }
|
||||
Button(attrs = {
|
||||
classes("link-button")
|
||||
onClick { viewModel.onBtcAmountChanged(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) }
|
||||
}) { Text("Set BTC to $maxSupplyText") }
|
||||
}
|
||||
}
|
||||
|
||||
// Currencies
|
||||
Div({ classes("card") }) {
|
||||
Div({ classes("row", "section-title-row") }) {
|
||||
Span({ classes("section-title") }) { Text(Strings.currenciesSectionTitle) }
|
||||
if (!state.isManualSource) {
|
||||
Button(attrs = { classes("outlined-button"); onClick { showCurrencyPicker = true } }) {
|
||||
Text(
|
||||
if (state.selectedFiatCurrencies.size <= 1) {
|
||||
Strings.addCurrency
|
||||
} else {
|
||||
Strings.currenciesSelectedCount(state.selectedFiatCurrencies.size)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val displayedCurrencies = if (state.isManualSource) {
|
||||
listOf(state.defaultCurrencyCode)
|
||||
} else {
|
||||
state.selectedFiatCurrencies
|
||||
}
|
||||
displayedCurrencies.forEachIndexed { index, code ->
|
||||
val isPriced = state.isPriced(code)
|
||||
val fieldLabel = currencyFlagEmoji(code, supportsFlagEmoji = true)?.let { flag -> "$flag $code" } ?: code
|
||||
Div({ classes("row", "currency-row") }) {
|
||||
LabeledInput(
|
||||
label = fieldLabel,
|
||||
value = if (isPriced) state.fiatAmounts[code].orEmpty() else "",
|
||||
enabled = isPriced,
|
||||
isError = !isPriced && !state.isManualSource,
|
||||
supportingText = if (!isPriced && !state.isManualSource) {
|
||||
Strings.currencyNotPriced(state.sourceName)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onValueChange = { viewModel.onFiatAmountChanged(code, it) },
|
||||
modifierClass = "grow",
|
||||
)
|
||||
if (displayedCurrencies.size > 1) {
|
||||
val selection = state.selectedFiatCurrencies
|
||||
CurrencyRowMenu(
|
||||
code = code,
|
||||
canMoveUp = index > 0,
|
||||
canMoveDown = index < selection.lastIndex,
|
||||
canRemove = code != state.defaultCurrencyCode,
|
||||
onMoveUp = { viewModel.onFiatCurrenciesReordered(selection.moved(index, index - 1)) },
|
||||
onMoveDown = { viewModel.onFiatCurrenciesReordered(selection.moved(index, index + 1)) },
|
||||
onMoveToTop = { viewModel.onFiatCurrenciesReordered(selection.moved(index, 0)) },
|
||||
onMoveToBottom = {
|
||||
viewModel.onFiatCurrenciesReordered(selection.moved(index, selection.lastIndex))
|
||||
},
|
||||
onRemove = { viewModel.onFiatCurrencyToggled(code) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<String>.moved(fromIndex: Int, toIndex: Int): List<String> =
|
||||
toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
|
||||
/**
|
||||
* An anchored popup menu for reordering/removing a currency row, matching Material3's
|
||||
* DropdownMenu behavior. Compose HTML has no built-in menu primitive, so this is plain DOM: a
|
||||
* relatively-positioned trigger, an absolutely-positioned popup anchored under it (CSS handles
|
||||
* the anchoring - no JS position math needed), and a full-viewport backdrop that closes the menu
|
||||
* on any outside click, matching how the Material3 version dismisses.
|
||||
*/
|
||||
@Composable
|
||||
private fun CurrencyRowMenu(
|
||||
code: String,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
canRemove: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
onMoveToTop: () -> Unit,
|
||||
onMoveToBottom: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Div({ classes("menu-anchor") }) {
|
||||
Button(attrs = {
|
||||
classes("icon-button")
|
||||
attr("aria-label", Strings.currencyOptions(code))
|
||||
attr("aria-haspopup", "true")
|
||||
attr("aria-expanded", if (expanded) "true" else "false")
|
||||
onClick { expanded = true }
|
||||
}) { Text("⋮") }
|
||||
|
||||
if (expanded) {
|
||||
Div({
|
||||
classes("menu-backdrop")
|
||||
onClick { expanded = false }
|
||||
})
|
||||
Div({ classes("menu-popup") }) {
|
||||
MenuItem(Strings.moveToTop, enabled = canMoveUp) { expanded = false; onMoveToTop() }
|
||||
MenuItem(Strings.moveUp, enabled = canMoveUp) { expanded = false; onMoveUp() }
|
||||
MenuItem(Strings.moveDown, enabled = canMoveDown) { expanded = false; onMoveDown() }
|
||||
MenuItem(Strings.moveToBottom, enabled = canMoveDown) { expanded = false; onMoveToBottom() }
|
||||
if (canRemove) {
|
||||
MenuItem(Strings.remove, destructive = true) { expanded = false; onRemove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuItem(label: String, enabled: Boolean = true, destructive: Boolean = false, onClick: () -> Unit) {
|
||||
Button(attrs = {
|
||||
classes("menu-item")
|
||||
if (destructive) classes("menu-item-destructive")
|
||||
if (!enabled) attr("disabled", "")
|
||||
onClick { onClick() }
|
||||
}) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LabeledInput(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
readOnly: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
supportingText: String? = null,
|
||||
modifierClass: String? = null,
|
||||
) {
|
||||
Div({
|
||||
classes("field")
|
||||
modifierClass?.let { classes(it) }
|
||||
if (isError) classes("field-error")
|
||||
}) {
|
||||
Span({ classes("field-label") }) { Text(label) }
|
||||
Input(type = InputType.Text, attrs = {
|
||||
value(value)
|
||||
if (readOnly) attr("readonly", "")
|
||||
if (!enabled) attr("disabled", "")
|
||||
onInput { event -> onValueChange(event.value) }
|
||||
})
|
||||
supportingText?.let { Div({ classes("supporting-text") }) { Text(it) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package xyz.tyiu.satsprice
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.jetbrains.compose.web.attributes.InputType
|
||||
import org.jetbrains.compose.web.dom.Button
|
||||
import org.jetbrains.compose.web.dom.Div
|
||||
import org.jetbrains.compose.web.dom.H2
|
||||
import org.jetbrains.compose.web.dom.Input
|
||||
import org.jetbrains.compose.web.dom.Span
|
||||
import org.jetbrains.compose.web.dom.Text
|
||||
import xyz.tyiu.satsprice.ui.ConverterUiState
|
||||
import xyz.tyiu.satsprice.ui.currentCurrency
|
||||
import xyz.tyiu.satsprice.ui.isPriced
|
||||
import xyz.tyiu.satsprice.ui.selectedOtherCurrencies
|
||||
import xyz.tyiu.satsprice.ui.unselectedCurrencies
|
||||
|
||||
@Composable
|
||||
internal fun HtmlCurrencyPicker(
|
||||
state: ConverterUiState,
|
||||
onToggle: (String) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
var showResetConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
if (showResetConfirmation) {
|
||||
Div({ classes("modal-overlay") }) {
|
||||
Div({ classes("modal") }) {
|
||||
H2 { Text(Strings.resetConfirmationTitle) }
|
||||
Div { Text(Strings.resetConfirmationMessage(state.defaultCurrencyCode)) }
|
||||
Div({ classes("row", "modal-actions") }) {
|
||||
Button(attrs = { onClick { showResetConfirmation = false } }) { Text(Strings.cancel) }
|
||||
Button(attrs = {
|
||||
classes("destructive-button")
|
||||
onClick {
|
||||
showResetConfirmation = false
|
||||
onReset()
|
||||
}
|
||||
}) { Text(Strings.resetButton) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Div({ classes("screen") }) {
|
||||
Div({ classes("row", "section-title-row") }) {
|
||||
H2 { Text(Strings.currenciesSectionTitle) }
|
||||
Div({ classes("row") }) {
|
||||
if (state.selectedFiatCurrencies.size > 1) {
|
||||
Button(attrs = { onClick { showResetConfirmation = true } }) { Text(Strings.resetButton) }
|
||||
}
|
||||
Button(attrs = { onClick { onDone() } }) { Text(Strings.done) }
|
||||
}
|
||||
}
|
||||
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
Input(type = InputType.Text, attrs = {
|
||||
classes("search-input")
|
||||
attr("placeholder", Strings.searchCurrenciesPlaceholder)
|
||||
value(searchQuery)
|
||||
onInput { event -> searchQuery = event.value }
|
||||
})
|
||||
|
||||
val currentCurrency = state.currentCurrency()
|
||||
val selectedOthers = state.selectedOtherCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
|
||||
val unselected = state.unselectedCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
|
||||
val (unselectedPriced, unselectedUnpriced) = unselected.partition { state.isPriced(it.code) }
|
||||
|
||||
if (matchesCurrencySearch(currentCurrency, searchQuery)) {
|
||||
CurrencySection(Strings.currentCurrencySectionTitle) {
|
||||
CurrencyRow(currentCurrency, isSelected = true, isPriced = state.isPriced(currentCurrency.code), state, onClick = null)
|
||||
}
|
||||
}
|
||||
if (selectedOthers.isNotEmpty()) {
|
||||
CurrencySection(Strings.selectedCurrenciesSectionTitle) {
|
||||
selectedOthers.forEach { info ->
|
||||
CurrencyRow(info, isSelected = true, isPriced = state.isPriced(info.code), state) { onToggle(info.code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unselectedPriced.isNotEmpty()) {
|
||||
CurrencySection(Strings.pricedCurrenciesSectionTitle) {
|
||||
unselectedPriced.forEach { info ->
|
||||
CurrencyRow(info, isSelected = false, isPriced = true, state) { onToggle(info.code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unselectedUnpriced.isNotEmpty()) {
|
||||
CurrencySection(Strings.unpricedCurrenciesSectionTitle) {
|
||||
unselectedUnpriced.forEach { info ->
|
||||
CurrencyRow(info, isSelected = false, isPriced = false, state) { onToggle(info.code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrencySection(title: String, content: @Composable () -> Unit) {
|
||||
Div({ classes("currency-section") }) {
|
||||
Div({ classes("section-header") }) { Text(title.uppercase()) }
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrencyRow(
|
||||
info: CurrencyInfo,
|
||||
isSelected: Boolean,
|
||||
isPriced: Boolean,
|
||||
state: ConverterUiState,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val text = Strings.currencyOptionLabel(info.code, info.displayName)
|
||||
val label = currencyFlagEmoji(info.code, supportsFlagEmoji = true)?.let { flag -> "$flag $text" } ?: text
|
||||
Div({
|
||||
classes("row", "picker-row")
|
||||
if (onClick != null) {
|
||||
classes("clickable")
|
||||
onClick { onClick() }
|
||||
}
|
||||
}) {
|
||||
Div {
|
||||
Div { Text(label) }
|
||||
if (!isPriced) {
|
||||
Div({ classes("supporting-text", "error-text") }) {
|
||||
Text(Strings.currencyNotPriced(state.sourceName))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isSelected) Span({ classes("check-mark") }) { Text("✓") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package xyz.tyiu.satsprice
|
||||
|
||||
/**
|
||||
* English-only string constants mirroring shared/src/commonMain/moko-resources/base/strings.xml.
|
||||
* moko-resources-compose's `stringResource()` pulls in Compose Foundation/UI (for its
|
||||
* `LocalComposition`/`Modifier` plumbing) transitively, which would drag Skia-oriented
|
||||
* dependencies into a module whose whole point is avoiding them, so this DOM build skips
|
||||
* moko-resources rather than fight that dependency and stays English-only as a result.
|
||||
*/
|
||||
object Strings {
|
||||
const val addCurrency = "Add currency"
|
||||
const val bitcoinSectionTitle = "Bitcoin"
|
||||
const val btcLabel = "BTC"
|
||||
fun btcToCurrency(code: String) = "BTC to $code"
|
||||
const val cancel = "Cancel"
|
||||
const val clearSearch = "Clear search"
|
||||
const val currenciesSectionTitle = "Currencies"
|
||||
fun currenciesSelectedCount(count: Int) = "$count selected"
|
||||
const val currentCurrencySectionTitle = "Current Currency"
|
||||
fun currencyNotPriced(sourceName: String) = "Not priced by $sourceName"
|
||||
fun currencyOptions(code: String) = "Options for $code"
|
||||
fun currencyOptionLabel(code: String, displayName: String) = "$code - $displayName"
|
||||
fun currencyToSats(code: String) = "$code to Sats"
|
||||
const val done = "Done"
|
||||
fun exceedsMaxSupply(maxSupplyText: String) = "Exceeds the $maxSupplyText maximum supply"
|
||||
const val loadingRatesStatus = "Loading rates…"
|
||||
const val moveDown = "Move down"
|
||||
const val moveToBottom = "Move to bottom"
|
||||
const val moveToTop = "Move to top"
|
||||
const val moveUp = "Move up"
|
||||
const val priceSource = "Price Source"
|
||||
const val pricedCurrenciesSectionTitle = "Priced Currencies"
|
||||
const val refresh = "Refresh"
|
||||
const val remove = "Remove"
|
||||
const val resetButton = "Reset"
|
||||
fun resetConfirmationMessage(code: String) =
|
||||
"This keeps $code, but removes every other currency you've added."
|
||||
const val resetConfirmationTitle = "Remove all selected currencies?"
|
||||
const val retry = "Retry"
|
||||
const val satsLabel = "Sats"
|
||||
const val searchCurrenciesPlaceholder = "Search currencies"
|
||||
const val selectedCurrenciesSectionTitle = "Selected Currencies"
|
||||
const val unpricedCurrenciesSectionTitle = "Unpriced Currencies"
|
||||
fun updatedStatus(time: String) = "Updated $time"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package xyz.tyiu.satsprice
|
||||
|
||||
import org.jetbrains.compose.web.renderComposable
|
||||
|
||||
fun main() {
|
||||
renderComposable(rootElementId = "root") {
|
||||
HtmlApp()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SatsPrice (DOM)</title>
|
||||
<meta name="description" content="SatsPrice converts Bitcoin, satoshis, and fiat currency in real time — Compose HTML build.">
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="favicon-192.png" sizes="192x192">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="application/javascript" src="webHtmlApp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,314 @@
|
||||
:root {
|
||||
--bg: #fef7ff;
|
||||
--surface: #ffffff;
|
||||
--surface-container: #f3edf7;
|
||||
--on-surface: #1d1b20;
|
||||
--on-surface-variant: #49454f;
|
||||
--primary: #6750a4;
|
||||
--outline: #79747e;
|
||||
--error: #b3261e;
|
||||
--error-container: #f9dedc;
|
||||
--on-error-container: #410e0b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #141218;
|
||||
--surface: #211f26;
|
||||
--surface-container: #2b2930;
|
||||
--on-surface: #e6e0e9;
|
||||
--on-surface-variant: #cac4d0;
|
||||
--primary: #d0bcff;
|
||||
--outline: #948f99;
|
||||
--error: #f2b8b5;
|
||||
--error-container: #8c1d18;
|
||||
--on-error-container: #f9dedc;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--on-surface);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.screen {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
margin: 8px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-container);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.field-inline-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.field input {
|
||||
font-size: 1rem;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--outline);
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.field-error input {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.supporting-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--error-container);
|
||||
color: var(--on-error-container);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--error);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.currency-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.menu-anchor {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.menu-popup {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 16;
|
||||
margin-top: 4px;
|
||||
min-width: 200px;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--on-surface);
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.menu-item:not(:disabled):hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.menu-item-destructive {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--on-surface);
|
||||
font-size: 1.1rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.icon-button:not(:disabled):hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.outlined-button, .link-button, .destructive-button, .modal-actions button, .section-title-row button {
|
||||
border: 1px solid var(--outline);
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.destructive-button {
|
||||
color: var(--error);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--outline);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 1rem;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--outline);
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.currency-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.picker-row {
|
||||
justify-content: space-between;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--surface-container);
|
||||
}
|
||||
|
||||
.picker-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picker-row.clickable:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -7,12 +7,12 @@ PORT="${1:-8000}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "Building web app (wasmJs)..."
|
||||
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webApp:wasmJsBrowserDistribution
|
||||
echo "Building web app (webHtmlApp)..."
|
||||
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webHtmlApp:jsBrowserDistribution
|
||||
|
||||
echo "Copying web app into website/app/..."
|
||||
mkdir -p "$SCRIPT_DIR/app"
|
||||
cp -r "$REPO_ROOT/webApp/build/dist/wasmJs/productionExecutable/." "$SCRIPT_DIR/app/"
|
||||
cp -r "$REPO_ROOT/webHtmlApp/build/dist/js/productionExecutable/." "$SCRIPT_DIR/app/"
|
||||
|
||||
echo "Serving website at http://localhost:$PORT/ (web app at http://localhost:$PORT/app/)"
|
||||
cd "$SCRIPT_DIR" && python3 -m http.server "$PORT"
|
||||
|
||||
Reference in New Issue
Block a user