Compare commits
21 Commits
8cfcf07387
...
0a2398f507
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2398f507 | |||
| 397e606793 | |||
| 89132f6745 | |||
| 7170be016d | |||
| 1d1be5fbe9 | |||
| 859f41dcb9 | |||
| d6fe0b0597 | |||
| 684469273f | |||
| 057a00c413 | |||
| 01f43e1f67 | |||
| cf02eeb991 | |||
| 2e4187c850 | |||
| 478b4ce44e | |||
| 66d0624279 | |||
| dcfddc88c7 | |||
| 5267f05089 | |||
| b7ec4b1041 | |||
| 8466ed4d08 | |||
| f64688a16f | |||
| a177b02145 | |||
| 31e2a3f30a |
@@ -0,0 +1,29 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm typecheck
|
||||||
|
- run: pnpm lint
|
||||||
|
- run: pnpm test
|
||||||
|
- run: pnpm build
|
||||||
|
- run: pnpm check:size
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|||||||
Generated
+159
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -11,6 +17,21 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -268,6 +289,27 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -424,6 +466,15 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
@@ -581,6 +632,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1291,12 +1352,48 @@ version = "2.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memory-serve"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81b5bbad2035f57b1e95f66da606832edd935b47d82312e38e1ccffbcfb8a427"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"brotli",
|
||||||
|
"flate2",
|
||||||
|
"mime_guess",
|
||||||
|
"sha256",
|
||||||
|
"tracing",
|
||||||
|
"urlencoding",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -1866,6 +1963,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1970,6 +2076,8 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"db",
|
"db",
|
||||||
"domain",
|
"domain",
|
||||||
|
"http-body-util",
|
||||||
|
"memory-serve",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
"search",
|
"search",
|
||||||
@@ -1977,6 +2085,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"temp-env",
|
"temp-env",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -2003,6 +2112,19 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha256"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"hex",
|
||||||
|
"sha2",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -2038,6 +2160,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2740,6 +2868,12 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -2803,6 +2937,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2869,6 +3009,16 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3058,6 +3208,15 @@ dependencies = [
|
|||||||
"wasite",
|
"wasite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ argon2 = "0.5"
|
|||||||
tower-sessions = "0.14"
|
tower-sessions = "0.14"
|
||||||
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
|
memory-serve = "2.1"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ path = "src/lib.rs"
|
|||||||
name = "server"
|
name = "server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
embed-web = ["dep:memory-serve"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
@@ -24,10 +27,16 @@ db = { path = "../db" }
|
|||||||
domain = { path = "../domain" }
|
domain = { path = "../domain" }
|
||||||
search = { path = "../search" }
|
search = { path = "../search" }
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
|
memory-serve = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
memory-serve = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
tower.workspace = true
|
||||||
|
http-body-util.workspace = true
|
||||||
api = { path = "../api" }
|
api = { path = "../api" }
|
||||||
auth = { path = "../auth" }
|
auth = { path = "../auth" }
|
||||||
db = { path = "../db" }
|
db = { path = "../db" }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fn main() {
|
||||||
|
if std::env::var("CARGO_FEATURE_EMBED_WEB").is_ok() {
|
||||||
|
memory_serve::load_directory("../../web/dist");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
|
#[cfg(feature = "embed-web")]
|
||||||
|
mod web_assets;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
@@ -65,6 +68,9 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
||||||
let app = build_app(state);
|
let app = build_app(state);
|
||||||
|
|
||||||
|
#[cfg(feature = "embed-web")]
|
||||||
|
let app = app.merge(web_assets::routes());
|
||||||
|
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.context("running the HTTP server")?;
|
.context("running the HTTP server")?;
|
||||||
@@ -72,6 +78,14 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "embed-web")]
|
||||||
|
pub mod test_support {
|
||||||
|
/// The SPA-asset router, for tests.
|
||||||
|
pub fn web_router() -> axum::Router {
|
||||||
|
super::web_assets::routes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
|
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
|
||||||
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
|
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
|
||||||
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
|
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Serves the embedded SPA (built `web/dist`) at `/` with a client-side-routing
|
||||||
|
//! fallback. Compiled only with the `embed-web` feature; in dev the SPA is served by
|
||||||
|
//! Vite (which proxies `/api` to this server), so this module is absent.
|
||||||
|
|
||||||
|
use axum::{Router, http::StatusCode};
|
||||||
|
|
||||||
|
/// A router that serves the embedded `web/dist` assets, falling back to `index.html`
|
||||||
|
/// for unknown paths so the SPA can own client-side routes.
|
||||||
|
pub(crate) fn routes() -> Router {
|
||||||
|
memory_serve::load!()
|
||||||
|
.index_file(Some("/index.html"))
|
||||||
|
.fallback(Some("/index.html"))
|
||||||
|
.fallback_status(StatusCode::OK)
|
||||||
|
.into_router()
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! Only meaningful with `--features embed-web` and a built `web/dist`. Compiled only
|
||||||
|
//! under that feature.
|
||||||
|
#![cfg(feature = "embed-web")]
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn serves_index_at_root_and_spa_fallback() {
|
||||||
|
let app = server::test_support::web_router();
|
||||||
|
|
||||||
|
let root = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(root.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = root.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
|
||||||
|
assert!(String::from_utf8_lossy(&body).contains("<div id=\"root\">"));
|
||||||
|
|
||||||
|
let deep = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/objects/123")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(deep.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
|||||||
|
# Frontend SPA — Milestone 1 (Foundation Slice) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The backend MVP is code-complete: an authenticated admin HTTP API (object CRUD +
|
||||||
|
flexible fields, vocabulary/authority/term management, stepwise publishing, search,
|
||||||
|
audit), a public read API, and a code-first OpenAPI document (utoipa). There is **no
|
||||||
|
frontend yet**. VISION + `docs/specs/2026-06-02-mvp-architecture.md` §17 call for a
|
||||||
|
**lean React SPA** in `web/` that consumes the OpenAPI, with sv/en localization and an
|
||||||
|
explicit "potato hardware" bundle-discipline budget.
|
||||||
|
|
||||||
|
The admin UI is a large surface, so it is **decomposed into milestones**, each with its
|
||||||
|
own spec → plan → implementation cycle:
|
||||||
|
|
||||||
|
1. **Foundation slice (this spec):** scaffold, build/dev/serve, typed API client, app
|
||||||
|
shell (nav + i18n), login/session, and the Objects screen (paginated list +
|
||||||
|
read-only detail).
|
||||||
|
2. Object authoring — create/edit/delete + the dynamic flexible-field form driven by
|
||||||
|
`field-definitions`.
|
||||||
|
3. Publishing workflow — stepwise draft→internal→public UI with the required-field gate.
|
||||||
|
4. Vocabulary & authority management.
|
||||||
|
5. Search UI.
|
||||||
|
|
||||||
|
## Goal (Milestone 1)
|
||||||
|
|
||||||
|
A walking skeleton that proves the whole pipeline end-to-end — **log in → fetch →
|
||||||
|
render → embedded in the binary** — and lands a real, usable screen (browse + read
|
||||||
|
catalogue objects). Every piece of plumbing here is reused by later milestones.
|
||||||
|
|
||||||
|
## Decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
- **Stack:** React + TypeScript + **Vite**, **pnpm**, **shadcn/ui** components,
|
||||||
|
**TanStack Query** for server state, **react-i18next** for sv/en.
|
||||||
|
- **API client (Option A):** `openapi-typescript` generates types from the OpenAPI
|
||||||
|
JSON; `openapi-fetch` (tiny typed wrapper) for calls; TanStack Query hooks written by
|
||||||
|
hand. Smallest dependency surface, contract auto-synced, query layer under our control.
|
||||||
|
- **Layout (Option C):** two-pane **master–detail** — object list on the left, the
|
||||||
|
selected record fills the right pane (inspector style; efficient for browse + later
|
||||||
|
edit-in-place).
|
||||||
|
- **Testing (Option A):** **Vitest + React Testing Library + MSW** (Mock Service Worker)
|
||||||
|
— component/hook tests in-process; MSW intercepts fetches with handlers typed against
|
||||||
|
the generated schema, so tests stay honest to the OpenAPI contract without a browser.
|
||||||
|
A Playwright e2e smoke is deferred to a later milestone (fits once `--seed` data exists).
|
||||||
|
- **Bundle budget:** initial JS **≤ 150 KB gzipped**, tracked in CI.
|
||||||
|
|
||||||
|
## Scope (YAGNI)
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
- `web/` scaffold + build/dev/serve (incl. release embedding).
|
||||||
|
- Generated typed API client + TanStack Query hooks.
|
||||||
|
- App shell: compact icon sidebar (Objects active; later items shown **disabled /
|
||||||
|
"coming soon"**), top bar with sv/en switch + user menu (logout).
|
||||||
|
- Auth: login page, logout, session guard.
|
||||||
|
- **Objects two-pane:** paginated list (left) + **read-only** detail (right) showing the
|
||||||
|
inventory-minimum fields and flexible-field *values*, with a visibility badge.
|
||||||
|
|
||||||
|
**Out (later milestones):** create/edit/delete and dynamic field *forms* (M2), publish
|
||||||
|
workflow (M3), vocabulary/authority/term management (M4), search UI (M5), media/
|
||||||
|
thumbnails. Later nav items appear as disabled stubs so the shell's shape is visible.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Project layout (`web/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
package.json pnpm; scripts: dev, build, test, typecheck, lint, gen:api
|
||||||
|
vite.config.ts dev proxy /api,/api-docs,/health -> http://localhost:8080
|
||||||
|
tsconfig.json
|
||||||
|
index.html
|
||||||
|
components.json shadcn/ui
|
||||||
|
src/
|
||||||
|
main.tsx entry: QueryClientProvider, i18n, router
|
||||||
|
app.tsx route table
|
||||||
|
api/
|
||||||
|
schema.d.ts generated (openapi-typescript) — committed
|
||||||
|
client.ts openapi-fetch client; credentials:'include'; 401 middleware
|
||||||
|
queries.ts useMe, useObjectsPage, useObject, useLogin, useLogout
|
||||||
|
auth/
|
||||||
|
session.tsx session context (via /api/admin/me); RequireAuth guard
|
||||||
|
login-page.tsx
|
||||||
|
shell/
|
||||||
|
app-shell.tsx icon sidebar + top bar + <Outlet/>
|
||||||
|
lang-switch.tsx
|
||||||
|
objects/
|
||||||
|
objects-page.tsx two-pane container (list + detail by :id)
|
||||||
|
object-list.tsx
|
||||||
|
object-detail.tsx
|
||||||
|
visibility-badge.tsx
|
||||||
|
i18n/
|
||||||
|
index.ts react-i18next init
|
||||||
|
en.json sv.json
|
||||||
|
components/ui/... shadcn primitives
|
||||||
|
test/
|
||||||
|
setup.ts Vitest + RTL + MSW server
|
||||||
|
handlers.ts MSW handlers typed against schema.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serve / build model
|
||||||
|
|
||||||
|
- **Dev:** `pnpm dev` runs Vite on `:5173`, proxying `/api`, `/api-docs`, `/health` to
|
||||||
|
the Rust server on `:8080` (run separately). Session cookies stay same-origin through
|
||||||
|
the proxy. Backend dev/test loop is unchanged and independent of the frontend.
|
||||||
|
- **Release:** `pnpm build` → `web/dist`, embedded into the **`server`** binary via the
|
||||||
|
**`memory-serve`** crate and served at `/` with an SPA fallback (any non-`/api`,
|
||||||
|
non-`/health`, non-`/api-docs` path → `index.html` for client-side routing).
|
||||||
|
- **Feature gate `embed-web`** (off by default) on the `server` crate guards the
|
||||||
|
memory-serve embedding, so `cargo build`/`cargo test` never require a built `web/dist`.
|
||||||
|
CI builds the SPA first, then `cargo build -p server --features embed-web` for the
|
||||||
|
release artifact. **Milestone 1 proves this embed path end-to-end.**
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
1. App mounts → `useMe()` queries `/api/admin/me`. 200 → session established; 401 → the
|
||||||
|
client middleware redirects to `/login`.
|
||||||
|
2. `/objects` → `useObjectsPage(limit, offset)` → `GET /api/admin/objects` (the paginated
|
||||||
|
admin list already built). Left pane renders rows; selecting one routes to
|
||||||
|
`/objects/:id`.
|
||||||
|
3. `/objects/:id` → `useObject(id)` → `GET /api/admin/objects/{id}`. Right pane renders
|
||||||
|
inventory-minimum fields + flexible-field values + visibility badge; 404 → not-found
|
||||||
|
state.
|
||||||
|
4. Login: `useLogin` → `POST /api/admin/login` (session cookie set); on success invalidate
|
||||||
|
`useMe` and navigate to `/objects`. Logout: `POST /api/admin/logout` → clear → `/login`.
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
| Path | Access | Renders |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `/login` | public | login page (redirects to `/objects` if already authed) |
|
||||||
|
| `/` | protected | redirect → `/objects` |
|
||||||
|
| `/objects` | protected | two-pane; empty right pane prompt |
|
||||||
|
| `/objects/:id` | protected | two-pane; right pane = record |
|
||||||
|
| `*` | protected | redirect → `/objects` |
|
||||||
|
|
||||||
|
`RequireAuth` wraps protected routes; unauthenticated → `/login`.
|
||||||
|
|
||||||
|
## Error / loading / empty states
|
||||||
|
|
||||||
|
- **List:** loading skeleton; empty ("no objects yet"); error with retry.
|
||||||
|
- **Detail:** loading; 404 not-found; error with retry.
|
||||||
|
- **Login:** inline invalid-credentials (401) and network-error messages.
|
||||||
|
- **Visibility:** i18n'd badge — draft (neutral) / internal (amber) / public (green).
|
||||||
|
- All user-facing copy lives in `en.json` + `sv.json`; language switch persists to
|
||||||
|
`localStorage`.
|
||||||
|
|
||||||
|
## Testing & CI
|
||||||
|
|
||||||
|
- **Vitest + RTL + MSW.** `test/setup.ts` starts an MSW server; `handlers.ts` returns
|
||||||
|
realistic responses typed against `schema.d.ts`. Coverage for M1:
|
||||||
|
- API client + query hooks (success + error mapping).
|
||||||
|
- Login flow: success → navigates; 401 → inline error.
|
||||||
|
- Objects list: renders rows, pagination controls, empty + error states.
|
||||||
|
- Object detail: renders fields + visibility; 404 state.
|
||||||
|
- Language switch toggles copy.
|
||||||
|
- `RequireAuth` redirects unauthenticated users to `/login`.
|
||||||
|
- **CI (new `web` job):** `pnpm install` → `tsc` typecheck → eslint → `vitest run` →
|
||||||
|
`vite build` → **bundle-size check (initial JS ≤ 150 KB gz)**.
|
||||||
|
|
||||||
|
## Acceptance criteria (Milestone 1 "done")
|
||||||
|
|
||||||
|
1. `pnpm dev` + the running server: can log in, see a paginated object list, select a
|
||||||
|
row, read its detail (incl. flexible-field values), switch sv/en, and log out.
|
||||||
|
2. Unauthenticated access to a protected route redirects to `/login`; a 401 mid-session
|
||||||
|
bounces to `/login`.
|
||||||
|
3. `cargo build -p server --features embed-web` (after `pnpm build`) produces a binary
|
||||||
|
that serves the SPA at `/` with working client-side routing; backend `cargo test`
|
||||||
|
still passes **without** a built frontend.
|
||||||
|
4. `web` CI job green: typecheck, lint, tests, build, bundle-size within budget.
|
||||||
|
5. Later nav items are visible but disabled.
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- Playwright e2e smoke (later milestone, once `--seed` data exists; relates to issue #14).
|
||||||
|
- Object create/edit/delete + dynamic field forms (Milestone 2).
|
||||||
|
- Any write operations from the UI (M1 is read-only beyond auth).
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "src/components",
|
||||||
|
"utils": "src/lib/utils",
|
||||||
|
"ui": "src/components/ui",
|
||||||
|
"lib": "src/lib",
|
||||||
|
"hooks": "src/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", "src/components/ui", "src/api/schema.d.ts"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
|
||||||
|
plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Collection</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"gen:api": "openapi-typescript http://localhost:8080/api-docs/openapi.json -o src/api/schema.d.ts",
|
||||||
|
"check:size": "node scripts/check-bundle-size.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"i18next": "^26.3.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
|
"react-router-dom": "^7.16.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^19.1.5",
|
||||||
|
"@types/react-dom": "^19.1.3",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"msw": "^2.14.6",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"shadcn": "^4.10.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.60.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vitest": "^3.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5800
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
msw: true
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Fails if the largest built JS entry chunk exceeds the gzipped budget.
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const BUDGET_KB = 150;
|
||||||
|
const dir = "dist/assets";
|
||||||
|
const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
|
||||||
|
if (jsFiles.length === 0) {
|
||||||
|
console.error(`no JS files found in ${dir} — was the build skipped?`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let largest = 0;
|
||||||
|
let largestName = "";
|
||||||
|
for (const file of jsFiles) {
|
||||||
|
const gz = gzipSync(readFileSync(join(dir, file))).length;
|
||||||
|
if (gz > largest) { largest = gz; largestName = file; }
|
||||||
|
}
|
||||||
|
const kb = (largest / 1024).toFixed(1);
|
||||||
|
console.log(`largest JS chunk: ${largestName} = ${kb} KB gz (budget ${BUDGET_KB} KB)`);
|
||||||
|
if (largest > BUDGET_KB * 1024) {
|
||||||
|
console.error(`bundle-size budget exceeded: ${kb} KB > ${BUDGET_KB} KB`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped
|
||||||
|
* for a router navigation if needed. */
|
||||||
|
export function redirectToLogin(): void {
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
window.location.assign("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import * as authRedirect from "./auth-redirect";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
describe("api client", () => {
|
||||||
|
test("returns typed data on success", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("/api/admin/me", () =>
|
||||||
|
HttpResponse.json({ id: "u1", email: "a@b.se", role: "admin" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error } = await api.GET("/api/admin/me");
|
||||||
|
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(data?.email).toBe("a@b.se");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 401 triggers the auth redirect", async () => {
|
||||||
|
const spy = vi.spyOn(authRedirect, "redirectToLogin").mockImplementation(() => {});
|
||||||
|
|
||||||
|
server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 })));
|
||||||
|
|
||||||
|
await api.GET("/api/admin/me");
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import createClient, { type Middleware } from "openapi-fetch";
|
||||||
|
|
||||||
|
import type { paths } from "./schema";
|
||||||
|
import { redirectToLogin } from "./auth-redirect";
|
||||||
|
|
||||||
|
const onUnauthorized: Middleware = {
|
||||||
|
async onResponse({ response }) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = createClient<paths>({
|
||||||
|
baseUrl: window.location.origin,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
api.use(onUnauthorized);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
import type { components } from "./schema";
|
||||||
|
|
||||||
|
type UserView = components["schemas"]["UserView"];
|
||||||
|
type LoginRequest = components["schemas"]["LoginRequest"];
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: async (): Promise<UserView | null> => {
|
||||||
|
const { data, response } = await api.GET("/api/admin/me");
|
||||||
|
|
||||||
|
if (response.status === 401) return null;
|
||||||
|
|
||||||
|
if (!data) throw new Error("failed to load session");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObjectsPage(limit: number, offset: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["objects", { limit, offset }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/objects", {
|
||||||
|
params: { query: { limit, offset } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load objects");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObject(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["object", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await api.GET("/api/admin/objects/{id}", {
|
||||||
|
params: { path: { id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
|
||||||
|
if (!data) throw new Error("failed to load object");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
// A 404 resolves to null rather than erroring, so don't retry it.
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFieldDefinitions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["field-definitions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/field-definitions");
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load field definitions");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: LoginRequest) => {
|
||||||
|
const { response } = await api.POST("/api/admin/login", { body });
|
||||||
|
|
||||||
|
if (response.status !== 204) {
|
||||||
|
throw new Error(response.status === 401 ? "invalid" : "network");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.POST("/api/admin/logout");
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.setQueryData(["me"], null),
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+1232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { App } from "./app";
|
||||||
|
import "./i18n";
|
||||||
|
|
||||||
|
test("mounts and routes to a known screen", async () => {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/object|föremål|sign in|logga in/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { RequireAuth } from "./auth/require-auth";
|
||||||
|
import { LoginPage } from "./auth/login-page";
|
||||||
|
import { AppShell } from "./shell/app-shell";
|
||||||
|
import { ObjectsPage } from "./objects/objects-page";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/objects" element={<ObjectsPage />} />
|
||||||
|
<Route path="/objects/:id" element={<ObjectsPage />} />
|
||||||
|
<Route path="/" element={<Navigate to="/objects" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { LoginPage } from "./login-page";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/objects" element={<div>objects landing</div>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("successful login navigates to /objects", async () => {
|
||||||
|
renderApp(tree(), { route: "/login" });
|
||||||
|
await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com");
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
expect(await screen.findByText("objects landing")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid credentials show an inline error", async () => {
|
||||||
|
server.use(http.post("/api/admin/login", () => new HttpResponse(null, { status: 401 })));
|
||||||
|
renderApp(tree(), { route: "/login" });
|
||||||
|
await userEvent.type(screen.getByLabelText(/email/i), "x@y.se");
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), "wrong");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useLogin } from "../api/queries";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const login = useLogin();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
login.mutate(
|
||||||
|
{ email, password },
|
||||||
|
{ onSuccess: () => navigate("/objects", { replace: true }) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorKey = login.error
|
||||||
|
? login.error.message === "invalid"
|
||||||
|
? "auth.invalid"
|
||||||
|
: "auth.networkError"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">{t("app.name")}</h1>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorKey && (
|
||||||
|
<p role="alert" className="text-sm text-red-600">
|
||||||
|
{t(errorKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={login.isPending}>
|
||||||
|
{t("auth.signIn")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { RequireAuth } from "./require-auth";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<div>login page</div>} />
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route path="/objects" element={<div>secret objects</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders children when authenticated", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
expect(await screen.findByText("secret objects")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects to /login when unauthenticated", async () => {
|
||||||
|
server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 })));
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
await waitFor(() => expect(screen.getByText("login page")).toBeInTheDocument());
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useMe } from "../api/queries";
|
||||||
|
|
||||||
|
export function RequireAuth() {
|
||||||
|
const { data: user, isLoading } = useMe();
|
||||||
|
|
||||||
|
if (isLoading) return <div role="status" aria-label="loading" />;
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"app": { "name": "Collection" },
|
||||||
|
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" },
|
||||||
|
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||||
|
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" },
|
||||||
|
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
||||||
|
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { expect, test, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "./index";
|
||||||
|
import { useLocale } from "./use-locale";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave the shared i18n singleton on English so language-dependent assertions in
|
||||||
|
// other test files are never affected by this file's runtime switch to Swedish.
|
||||||
|
afterEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { setLocale } = useLocale();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="title">{t("objects.title")}</span>
|
||||||
|
<button onClick={() => setLocale("sv")}>sv</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("switches language at runtime", async () => {
|
||||||
|
render(<Probe />);
|
||||||
|
expect(screen.getByTestId("title")).toHaveTextContent("Objects");
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("button", { name: "sv" }).click();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("title")).toHaveTextContent("Föremål");
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import en from "./en.json";
|
||||||
|
import sv from "./sv.json";
|
||||||
|
|
||||||
|
export const LOCALE_KEY = "locale";
|
||||||
|
const stored =
|
||||||
|
typeof localStorage !== "undefined" ? localStorage.getItem(LOCALE_KEY) : null;
|
||||||
|
const fallback = "en";
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: { en: { translation: en }, sv: { translation: sv } },
|
||||||
|
lng: stored ?? fallback,
|
||||||
|
fallbackLng: fallback,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"app": { "name": "Samling" },
|
||||||
|
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" },
|
||||||
|
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||||
|
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" },
|
||||||
|
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
||||||
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n, { LOCALE_KEY } from "./index";
|
||||||
|
|
||||||
|
export function useLocale() {
|
||||||
|
const { i18n: instance } = useTranslation();
|
||||||
|
|
||||||
|
const setLocale = (lng: "en" | "sv") => {
|
||||||
|
localStorage.setItem(LOCALE_KEY, lng);
|
||||||
|
void i18n.changeLanguage(lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { locale: instance.language, setLocale };
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "@fontsource-variable/geist";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { App } from "./app";
|
||||||
|
import "./index.css";
|
||||||
|
import "./i18n";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ObjectDetail } from "./object-detail";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects/:id" element={<ObjectDetail />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders inventory-minimum fields, flexible values and visibility", async () => {
|
||||||
|
// override so the object carries a flexible field value (schema types fields as
|
||||||
|
// Record<string,never>, so return a plain object literal here)
|
||||||
|
server.use(
|
||||||
|
http.get("/api/admin/objects/:id", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
|
object_number: "LM-0042",
|
||||||
|
object_name: "Amphora",
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: "Storage jar",
|
||||||
|
current_location: "Vault 3",
|
||||||
|
current_owner: null,
|
||||||
|
recorder: null,
|
||||||
|
recording_date: null,
|
||||||
|
visibility: "public",
|
||||||
|
fields: { material: "Bronze" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
||||||
|
expect(await screen.findByText("Amphora")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Vault 3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Bronze")).toBeInTheDocument(); // flexible field value
|
||||||
|
expect(screen.getByText("Public")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a not-found state for a missing object", async () => {
|
||||||
|
server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 404 })));
|
||||||
|
renderApp(tree(), { route: "/objects/does-not-exist" });
|
||||||
|
expect(await screen.findByText(/object not found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useObject, useFieldDefinitions } from "../api/queries";
|
||||||
|
import { VisibilityBadge } from "./visibility-badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number | null | undefined;
|
||||||
|
}) {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div>
|
||||||
|
<div className="text-sm text-neutral-900">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectDetail() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { id } = useParams();
|
||||||
|
const { data: object, isLoading, isError } = useObject(id!);
|
||||||
|
const { data: definitions } = useFieldDefinitions();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>;
|
||||||
|
|
||||||
|
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>;
|
||||||
|
|
||||||
|
// Prefer the active locale's label, then English, then the raw key.
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
const labelFor = (key: string) => {
|
||||||
|
const labels = definitions?.find((d) => d.key === key)?.labels;
|
||||||
|
const byLang = labels?.find((l) => l.lang === lang)?.label;
|
||||||
|
const byEnglish = labels?.find((l) => l.lang === "en")?.label;
|
||||||
|
|
||||||
|
return byLang ?? byEnglish ?? key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flexible = Object.entries(object.fields as Record<string, unknown>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<h2 className="text-xl font-semibold">{object.object_name}</h2>
|
||||||
|
<VisibilityBadge visibility={object.visibility} />
|
||||||
|
</div>
|
||||||
|
<Field label={t("fieldsLabels.objectNumber")} value={object.object_number} />
|
||||||
|
<Field label={t("fieldsLabels.count")} value={object.number_of_objects} />
|
||||||
|
<Field label={t("fieldsLabels.briefDescription")} value={object.brief_description} />
|
||||||
|
<Field label={t("fieldsLabels.currentLocation")} value={object.current_location} />
|
||||||
|
<Field label={t("fieldsLabels.currentOwner")} value={object.current_owner} />
|
||||||
|
<Field label={t("fieldsLabels.recorder")} value={object.recorder} />
|
||||||
|
<Field label={t("fieldsLabels.recordingDate")} value={object.recording_date} />
|
||||||
|
{flexible.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-1 text-xs font-medium uppercase text-neutral-500">
|
||||||
|
{t("fieldsLabels.flexible")}
|
||||||
|
</div>
|
||||||
|
{flexible.map(([key, value]) => (
|
||||||
|
<Field
|
||||||
|
key={key}
|
||||||
|
label={labelFor(key)}
|
||||||
|
value={
|
||||||
|
value == null
|
||||||
|
? null
|
||||||
|
: typeof value === "object"
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: String(value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, expect, test } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ObjectList } from "./object-list";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects" element={<ObjectList />} />
|
||||||
|
<Route path="/objects/:id" element={<ObjectList />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders object rows with number, name and visibility", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
expect(await screen.findByText("LM-0042")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Amphora")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Public")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows an empty state when there are no objects", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("/api/admin/objects", () =>
|
||||||
|
HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
expect(await screen.findByText(/no objects yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows an error state on failure", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
expect(await screen.findByText(/could not load objects/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useObjectsPage } from "../api/queries";
|
||||||
|
import { VisibilityBadge } from "./visibility-badge";
|
||||||
|
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
export function ObjectList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useObjectsPage(LIMIT, offset);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-9 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
return <p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = data.total === 0 ? 0 : offset + 1;
|
||||||
|
const to = Math.min(offset + LIMIT, data.total);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<ul className="flex-1 overflow-auto">
|
||||||
|
{data.items.map((object) => (
|
||||||
|
<li key={object.id}>
|
||||||
|
<NavLink
|
||||||
|
to={`/objects/${object.id}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${
|
||||||
|
isActive ? "bg-indigo-50" : "hover:bg-neutral-50"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="text-neutral-500">{object.object_number}</span>{" "}
|
||||||
|
{object.object_name}
|
||||||
|
</span>
|
||||||
|
<VisibilityBadge visibility={object.visibility} />
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-neutral-500">
|
||||||
|
<span>
|
||||||
|
{from}–{to} {t("objects.of")} {data.total}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - LIMIT))}
|
||||||
|
>
|
||||||
|
{t("objects.prev")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={to >= data.total}
|
||||||
|
onClick={() => setOffset(offset + LIMIT)}
|
||||||
|
>
|
||||||
|
{t("objects.next")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ObjectsPage } from "./objects-page";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects" element={<ObjectsPage />} />
|
||||||
|
<Route path="/objects/:id" element={<ObjectsPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("selecting a row shows its detail in the right pane", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
// Wait for both the prompt (right pane) and the list rows (left pane) to load.
|
||||||
|
await screen.findByText(/select an object/i);
|
||||||
|
await userEvent.click(await screen.findByText("Amphora"));
|
||||||
|
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ObjectList } from "./object-list";
|
||||||
|
import { ObjectDetail } from "./object-detail";
|
||||||
|
|
||||||
|
export function ObjectsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||||
|
<div className="overflow-hidden border-r">
|
||||||
|
<ObjectList />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{id ? (
|
||||||
|
<ObjectDetail />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
|
||||||
|
{t("objects.selectPrompt")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const STYLES: Record<string, string> = {
|
||||||
|
draft: "bg-neutral-100 text-neutral-600",
|
||||||
|
internal: "bg-amber-100 text-amber-800",
|
||||||
|
public: "bg-green-100 text-green-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VisibilityBadge({ visibility }: { visibility: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
|
||||||
|
{t(`visibility.${visibility}`)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, test, beforeEach, afterEach } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { AppShell } from "./app-shell";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/objects" element={<div>objects outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<div>login page</div>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shows active and disabled nav and renders the outlet", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
|
||||||
|
// later milestones are present but disabled
|
||||||
|
expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("language switch toggles to Swedish", async () => {
|
||||||
|
renderApp(tree(), { route: "/objects" });
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "SV" }));
|
||||||
|
await waitFor(() => expect(screen.getByText("Föremål")).toBeInTheDocument());
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useLogout } from "../api/queries";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LangSwitch } from "./lang-switch";
|
||||||
|
|
||||||
|
const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const;
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
const onSignOut = () =>
|
||||||
|
logout.mutate(undefined, {
|
||||||
|
onSuccess: () => navigate("/login", { replace: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<aside className="w-44 shrink-0 border-r bg-neutral-50 p-3">
|
||||||
|
<div className="mb-4 font-semibold">{t("app.name")}</div>
|
||||||
|
<nav className="space-y-1 text-sm">
|
||||||
|
<NavLink
|
||||||
|
to="/objects"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.objects")}
|
||||||
|
</NavLink>
|
||||||
|
{FUTURE.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
disabled
|
||||||
|
title={t("nav.soon")}
|
||||||
|
className="block w-full cursor-not-allowed rounded px-2 py-1 text-left text-neutral-400"
|
||||||
|
>
|
||||||
|
{t(`nav.${key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<LangSwitch />
|
||||||
|
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
||||||
|
{t("auth.signOut")}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useLocale } from "../i18n/use-locale";
|
||||||
|
|
||||||
|
export function LangSwitch() {
|
||||||
|
const { locale, setLocale } = useLocale();
|
||||||
|
const base = locale.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 text-xs">
|
||||||
|
{(["sv", "en"] as const).map((lng) => (
|
||||||
|
<button
|
||||||
|
key={lng}
|
||||||
|
onClick={() => setLocale(lng)}
|
||||||
|
aria-pressed={base === lng}
|
||||||
|
className={base === lng ? "font-bold" : "text-neutral-400"}
|
||||||
|
>
|
||||||
|
{lng.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
export type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||||
|
export type AdminObjectPage = components["schemas"]["AdminObjectPage"];
|
||||||
|
|
||||||
|
export const amphora: AdminObjectView = {
|
||||||
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
|
object_number: "LM-0042",
|
||||||
|
object_name: "Amphora",
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: "Storage jar",
|
||||||
|
current_location: "Vault 3",
|
||||||
|
current_owner: null,
|
||||||
|
recorder: null,
|
||||||
|
recording_date: null,
|
||||||
|
visibility: "public",
|
||||||
|
fields: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fibula: AdminObjectView = {
|
||||||
|
...amphora,
|
||||||
|
id: "22222222-2222-2222-2222-222222222222",
|
||||||
|
object_number: "LM-0043",
|
||||||
|
object_name: "Bronze fibula",
|
||||||
|
visibility: "internal",
|
||||||
|
fields: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const objectsPage: AdminObjectPage = {
|
||||||
|
items: [amphora, fibula],
|
||||||
|
total: 2,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
test("default handler serves the objects page", async () => {
|
||||||
|
const { data } = await api.GET("/api/admin/objects", { params: { query: {} } });
|
||||||
|
|
||||||
|
expect(data?.total).toBe(2);
|
||||||
|
expect(data?.items[0].object_number).toBe("LM-0042");
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
|
||||||
|
import { amphora, fibula, objectsPage } from "./fixtures";
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.get("/api/admin/me", () =>
|
||||||
|
HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
|
||||||
|
),
|
||||||
|
|
||||||
|
http.get("/api/admin/objects", () => HttpResponse.json(objectsPage)),
|
||||||
|
|
||||||
|
http.get("/api/admin/objects/:id", ({ params }) => {
|
||||||
|
const found = [amphora, fibula].find((o) => o.id === params.id);
|
||||||
|
|
||||||
|
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/api/admin/field-definitions", () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{
|
||||||
|
key: "material",
|
||||||
|
data_type: "term",
|
||||||
|
vocabulary_id: "v1",
|
||||||
|
authority_kind: null,
|
||||||
|
required: false,
|
||||||
|
group: null,
|
||||||
|
labels: [{ lang: "en", label: "Material" }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
|
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import "../i18n";
|
||||||
|
|
||||||
|
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { setupServer } from "msw/node";
|
||||||
|
|
||||||
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
|
export const server = setupServer(...handlers);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { server } from "./server";
|
||||||
|
|
||||||
|
// Node v26 does not expose localStorage as a global unless --localstorage-file
|
||||||
|
// is passed. Provide a minimal in-memory shim so i18n and other modules that
|
||||||
|
// call localStorage.getItem/setItem work in jsdom tests.
|
||||||
|
if (typeof globalThis.localStorage === "undefined") {
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => store[key] ?? null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { Object.keys(store).forEach((k) => { delete store[k]; }); },
|
||||||
|
get length() { return Object.keys(store).length; },
|
||||||
|
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start MSW at module level so its fetch patch is in place before any test
|
||||||
|
// module captures globalThis.fetch via openapi-fetch's createClient().
|
||||||
|
server.listen({ onUnhandledRequest: "error" });
|
||||||
|
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"types": ["vitest/globals"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
"/api-docs": "http://localhost:8080",
|
||||||
|
"/health": "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
environmentOptions: {
|
||||||
|
jsdom: {
|
||||||
|
url: "http://localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user