Compare commits

...

21 Commits

Author SHA1 Message Date
logaritmisk 0a2398f507 fix(web): localize flexible-field labels to the active locale (sv/en)
CI / web (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:37:26 +02:00
logaritmisk 397e606793 ci(web): fail bundle check when no JS chunks found (avoid false green)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:31:08 +02:00
logaritmisk 89132f6745 ci(web): typecheck/lint/test/build + bundle-size budget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:28:36 +02:00
logaritmisk 7170be016d feat(server): embed SPA via memory-serve behind embed-web feature
Adds `memory-serve` 2.1 as an optional workspace dependency, a `build.rs`
that runs `load_directory` only when `CARGO_FEATURE_EMBED_WEB` is set, a
`web_assets` module serving `web/dist` at `/` with SPA fallback (200 OK)
for unknown client-side routes, and a feature-gated integration test.

The default build (no feature) compiles and tests cleanly without `web/dist`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:22:26 +02:00
logaritmisk 1d1be5fbe9 fix(web): hide null flexible-field values instead of rendering 'null'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:17:14 +02:00
logaritmisk 859f41dcb9 feat(web): object detail + two-pane page + app routing
Implements the navigable SPA shell: object detail pane showing
inventory-minimum fields, flexible fields (via Record<string,unknown>
cast) and visibility badge; ObjectsPage two-pane layout; BrowserRouter
wired through RequireAuth+AppShell; QueryClient provided in main.tsx.
Consolidates ObjectList NavLink to use isActive function form, removing
manual useParams highlight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:13:52 +02:00
logaritmisk d6fe0b0597 feat(web): paginated object list with visibility badges and states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:06:42 +02:00
logaritmisk 684469273f feat(web): app shell with sidebar nav, language switch, sign out
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:01:42 +02:00
logaritmisk 057a00c413 feat(web): login page with inline error handling
Add shadcn input/label/card primitives and implement the login page:
email/password form using useLogin, navigates to /objects on success,
shows inline i18n error on 401 (auth.invalid) or network failure.
2 new tests, 9 total green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:56:17 +02:00
logaritmisk 01f43e1f67 fix(web): disable retry on useObject (404 resolves to null)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:53:32 +02:00
logaritmisk cf02eeb991 feat(web): TanStack Query hooks + session-guarded routes
Installs @tanstack/react-query and react-router-dom; adds typed query
hooks (useMe, useObjectsPage, useObject, useFieldDefinitions, useLogin,
useLogout), a QueryClient+MemoryRouter test render helper, and
RequireAuth — a layout route that blocks unauthenticated access and
redirects to /login. All 7 tests pass, typecheck/lint/build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:49:55 +02:00
logaritmisk 2e4187c850 test(web): reset i18n to English after the language-switch test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:47:27 +02:00
logaritmisk 478b4ce44e feat(web): i18n with react-i18next (sv/en)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:42:47 +02:00
logaritmisk 66d0624279 test(web): MSW harness with typed handlers, fixtures, and client tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:35:55 +02:00
logaritmisk dcfddc88c7 feat(web): generated OpenAPI types + typed openapi-fetch client with 401 redirect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:25:10 +02:00
logaritmisk 5267f05089 fix(web): restore shadcn theme tokens in index.css; tidy deps + eslint rule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:21:58 +02:00
logaritmisk b7ec4b1041 feat(web): Tailwind 4 + shadcn/ui + ESLint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:14:12 +02:00
logaritmisk 8466ed4d08 chore(web): drop dangling favicon link (runtime 404)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:10:29 +02:00
logaritmisk f64688a16f feat(web): scaffold Vite + React + TS SPA with Vitest
Bootstraps the web/ SPA: Vite 6 + React 19 + TypeScript 5.8, Vitest
with jsdom, @testing-library/react, and a green smoke test asserting
the App renders its Collection heading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:06:03 +02:00
logaritmisk a177b02145 docs(plan): frontend SPA milestone 1 — task-by-task implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:56:54 +02:00
logaritmisk 31e2a3f30a docs(spec): frontend SPA milestone 1 (foundation slice) design
Decomposes the admin SPA into milestones; specs M1 — web/ scaffold,
Vite+React+TS+pnpm+shadcn/ui, openapi-typescript+openapi-fetch typed
client, TanStack Query, react-i18next (sv/en), two-pane master-detail
layout, login/session guard, read-only Objects browse, Vitest+RTL+MSW
tests, memory-serve embed behind a feature gate, 150KB bundle budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:50:30 +02:00
65 changed files with 10799 additions and 0 deletions
+29
View File
@@ -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
+5
View File
@@ -1,2 +1,7 @@
/target /target
.env .env
.superpowers/
web/node_modules/
web/dist/
Generated
+159
View File
@@ -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"
+1
View File
@@ -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"
+9
View File
@@ -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" }
+5
View File
@@ -0,0 +1,5 @@
fn main() {
if std::env::var("CARGO_FEATURE_EMBED_WEB").is_ok() {
memory_serve::load_directory("../../web/dist");
}
}
+14
View File
@@ -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
+15
View File
@@ -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()
}
+37
View File
@@ -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 **masterdetail** — 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).
+24
View File
@@ -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?
+25
View File
@@ -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": {}
}
+19
View File
@@ -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 }],
},
},
);
+12
View File
@@ -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>
+57
View File
@@ -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"
}
}
+5800
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
msw: true
+24
View File
@@ -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);
}
+7
View File
@@ -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");
}
}
+33
View File
@@ -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();
});
});
+21
View File
@@ -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);
+97
View File
@@ -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),
});
}
+1232
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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();
});
+24
View File
@@ -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>
);
}
+36
View File
@@ -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(),
);
});
+66
View File
@@ -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>
);
}
+30
View File
@@ -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());
});
+13
View File
@@ -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 />;
}
+52
View File
@@ -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 }
+58
View File
@@ -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 }
+103
View File
@@ -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,
}
+20
View File
@@ -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 }
+18
View File
@@ -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 }
+13
View File
@@ -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 }
+8
View File
@@ -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" }
}
+35
View File
@@ -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");
});
+18
View File
@@ -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;
+8
View File
@@ -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" }
}
+13
View File
@@ -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 };
}
+82
View File
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+19
View File
@@ -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>,
);
+48
View File
@@ -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();
});
+90
View File
@@ -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>
);
}
+46
View File
@@ -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();
});
+86
View File
@@ -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>
);
}
+23
View File
@@ -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();
});
+27
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+40
View File
@@ -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());
});
+59
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+34
View File
@@ -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,
};
+10
View File
@@ -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");
});
+35
View File
@@ -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 })),
];
+16
View File
@@ -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>,
);
}
+5
View File
@@ -0,0 +1,5 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
+29
View File
@@ -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());
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+22
View File
@@ -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"]
}
+31
View File
@@ -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",
},
},
},
});