diff --git a/client/.gitignore b/client/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/client/.gitignore
@@ -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?
diff --git a/client/.oxlintrc.json b/client/.oxlintrc.json
new file mode 100644
index 0000000..6fa991d
--- /dev/null
+++ b/client/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/client/.prettierignore b/client/.prettierignore
new file mode 100644
index 0000000..de4d1f0
--- /dev/null
+++ b/client/.prettierignore
@@ -0,0 +1,2 @@
+dist
+node_modules
diff --git a/client/.prettierrc b/client/.prettierrc
new file mode 100644
index 0000000..b2095be
--- /dev/null
+++ b/client/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "semi": false,
+ "singleQuote": true
+}
diff --git a/client/README.md b/client/README.md
new file mode 100644
index 0000000..d6af7e3
--- /dev/null
+++ b/client/README.md
@@ -0,0 +1,32 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the Oxlint configuration
+
+If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
+
+```json
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc"],
+ "options": {
+ "typeAware": true
+ },
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
+```
+
+See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..ea3d200
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Tapsailor
+
+
+
+
+
+
diff --git a/client/package-lock.json b/client/package-lock.json
new file mode 100644
index 0000000..0de38b7
--- /dev/null
+++ b/client/package-lock.json
@@ -0,0 +1,1312 @@
+{
+ "name": "tapfinder-client",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tapfinder-client",
+ "version": "0.0.0",
+ "dependencies": {
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.7",
+ "@vitejs/plugin-react": "^6.1.1",
+ "oxlint": "^1.81.0",
+ "prettier": "^3.9.9",
+ "typescript": "~6.0.2",
+ "vite": "^8.3.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.153.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
+ "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz",
+ "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz",
+ "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz",
+ "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz",
+ "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz",
+ "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz",
+ "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz",
+ "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz",
+ "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz",
+ "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz",
+ "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz",
+ "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz",
+ "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz",
+ "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz",
+ "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz",
+ "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz",
+ "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz",
+ "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz",
+ "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz",
+ "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
+ "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
+ "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
+ "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
+ "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
+ "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
+ "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
+ "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
+ "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
+ "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
+ "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
+ "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
+ "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
+ "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
+ "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
+ "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.19.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
+ "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
+ "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.152.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.87.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz",
+ "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.87.0",
+ "@oxlint/binding-android-arm64": "1.87.0",
+ "@oxlint/binding-darwin-arm64": "1.87.0",
+ "@oxlint/binding-darwin-x64": "1.87.0",
+ "@oxlint/binding-freebsd-x64": "1.87.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.87.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.87.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.87.0",
+ "@oxlint/binding-linux-arm64-musl": "1.87.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.87.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.87.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.87.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.87.0",
+ "@oxlint/binding-linux-x64-gnu": "1.87.0",
+ "@oxlint/binding-linux-x64-musl": "1.87.0",
+ "@oxlint/binding-openharmony-arm64": "1.87.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.87.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.87.0",
+ "@oxlint/binding-win32-x64-msvc": "1.87.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2003",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.9",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
+ "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
+ "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.153.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.13",
+ "@rolldown/binding-android-arm64": "1.2.13",
+ "@rolldown/binding-darwin-arm64": "1.2.13",
+ "@rolldown/binding-darwin-x64": "1.2.13",
+ "@rolldown/binding-freebsd-x64": "1.2.13",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.13",
+ "@rolldown/binding-linux-arm64-musl": "1.2.13",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.13",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.13",
+ "@rolldown/binding-linux-x64-gnu": "1.2.13",
+ "@rolldown/binding-linux-x64-musl": "1.2.13",
+ "@rolldown/binding-openharmony-arm64": "1.2.13",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.13",
+ "@rolldown/binding-win32-x64-msvc": "1.2.13"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.29",
+ "rolldown": "~1.2.12",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..a0215b9
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "tapfinder-client",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "oxlint",
+ "preview": "vite preview",
+ "format": "prettier --write .",
+ "format:check": "prettier --check ."
+ },
+ "dependencies": {
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.7",
+ "@vitejs/plugin-react": "^6.1.1",
+ "oxlint": "^1.81.0",
+ "prettier": "^3.9.9",
+ "typescript": "~6.0.2",
+ "vite": "^8.3.0"
+ }
+}
diff --git a/client/public/favicon.svg b/client/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/client/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/icons.svg b/client/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/client/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/client/src/App.css b/client/src/App.css
new file mode 100644
index 0000000..3f5c2af
--- /dev/null
+++ b/client/src/App.css
@@ -0,0 +1,583 @@
+/* ---------- Header ---------- */
+
+.site-header {
+ position: relative;
+ padding: 1rem 1rem 2.75rem;
+ background: linear-gradient(var(--navy-900), #0d2d54);
+ color: var(--eggshell);
+}
+
+.site-header :focus-visible {
+ outline-color: var(--blue-100);
+}
+
+/* Bølgekant mot eggeskall-bakgrunnen. Fargen må være skrevet inn i SVG-en. */
+.site-header::after {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: -1px;
+ left: 0;
+ height: 1.25rem;
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 20'%3E%3Cpath fill='%23f6f2e7' d='M0 20v-8q12-8 24 0t24 0 24 0 24 0v8Z'/%3E%3C/svg%3E")
+ repeat-x bottom / 6rem 1.25rem;
+}
+
+/* Sidemargen holder tittelen unna hjørneknappene. */
+.site-title {
+ padding-inline: 3.25rem;
+ text-align: center;
+}
+
+.site-title h1 {
+ margin: 0;
+ font-size: clamp(2rem, 5vw, 3rem);
+ line-height: 1.1;
+}
+
+/* Tagline med tynne streker på hver side. */
+.site-title p {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ margin: 0.25rem 0 0;
+ color: var(--blue-100);
+ font-size: clamp(1rem, 2.5vw, 1.25rem);
+}
+
+.site-title p::before,
+.site-title p::after {
+ content: '';
+ width: clamp(1rem, 6vw, 3rem);
+ height: 1px;
+ background: var(--blue-300);
+ opacity: 0.7;
+}
+
+.ship-icon {
+ width: 1.1em;
+ height: 1.02em;
+ vertical-align: -0.1em;
+ color: var(--blue-300);
+}
+
+/* Måker som flyr over havet. Vises bare når det er plass på sidene. */
+.gulls {
+ display: none;
+ position: absolute;
+ top: 0.75rem;
+ width: 5rem;
+ height: 2.3rem;
+ color: var(--blue-300);
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.gulls-left {
+ left: calc(50% - 21rem);
+}
+
+.gulls-right {
+ right: calc(50% - 21rem);
+}
+
+@media (min-width: 56rem) {
+ .gulls {
+ display: block;
+ }
+}
+
+/* Knappene sitter helt oppe i hvert sitt hjørne av siden. */
+.icon-button {
+ display: grid;
+ position: absolute;
+ top: 0.5rem;
+ place-items: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ padding: 0;
+ border: 2px solid var(--blue-300);
+ border-radius: 50%;
+ background: transparent;
+ color: var(--eggshell);
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ transform 0.15s;
+}
+
+.menu-button {
+ left: 0.5rem;
+}
+
+.help-button {
+ right: 0.5rem;
+}
+
+.icon-button svg {
+ width: 1.4rem;
+ height: 1.4rem;
+}
+
+.icon-button:hover {
+ background: var(--blue-300);
+ color: var(--navy-900);
+ transform: scale(1.08);
+}
+
+.icon-button:active {
+ transform: scale(1);
+}
+
+/* Tekstboble som åpnes av «?»-knappen. */
+.help-popover {
+ position: fixed;
+ inset: 3.9rem 0.5rem auto auto;
+ max-width: min(24rem, calc(100vw - 1rem));
+ margin: 0;
+ padding: 1rem 1.25rem;
+ overflow: visible;
+ border: 2px solid var(--navy-900);
+ border-radius: 1rem;
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: 0 0.75rem 2rem rgb(10 34 64 / 0.3);
+}
+
+.help-popover::before {
+ content: '';
+ position: absolute;
+ top: -0.6rem;
+ right: 0.85rem;
+ width: 1rem;
+ height: 1rem;
+ border: solid var(--navy-900);
+ border-width: 2px 0 0 2px;
+ background: var(--surface);
+ transform: rotate(45deg);
+}
+
+.help-popover h2 {
+ margin: 0 0 0.5rem;
+ font-size: 1.1rem;
+}
+
+.help-popover p {
+ margin: 0;
+}
+
+/* ---------- Navigasjon ---------- */
+
+.main-nav ul {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
+ gap: 0.5rem;
+ max-width: 52rem;
+ margin: 1rem auto 0;
+ padding: 0;
+ list-style: none;
+}
+
+.main-nav a {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45rem;
+ padding: 0.6rem 0.75rem;
+ border-radius: 0.6rem;
+ background: var(--navy-700);
+ color: var(--eggshell);
+ font-weight: 600;
+ text-decoration: none;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ transform 0.15s;
+}
+
+.nav-icon {
+ flex: none;
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.main-nav a:hover {
+ background: var(--blue-300);
+ color: var(--navy-900);
+ transform: translateY(-2px);
+}
+
+/* Siden vi står på: lys «dekk»-markering. */
+.main-nav a[aria-current='page'] {
+ background: var(--eggshell);
+ color: var(--navy-900);
+ box-shadow: inset 0 -3px 0 var(--blue-300);
+}
+
+.main-nav a[aria-current='page']:hover {
+ background: var(--blue-100);
+}
+
+/* ---------- Innhold ---------- */
+
+.page {
+ display: grid;
+ gap: 2rem;
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 1.5rem 1rem 3rem;
+}
+
+@media (min-width: 56rem) {
+ .page {
+ grid-template-columns: minmax(0, 26rem) minmax(0, 1fr);
+ align-items: start;
+ }
+}
+
+/*
+ * På brede skjermer er boksen like høy som vinduet, og bildet tar all ledig
+ * plass. Størrelsen er den samme hele tiden: man blar ned til den, og så
+ * følger den med nedover mens man blar i listen.
+ */
+@media (min-width: 56rem) {
+ .featured {
+ position: sticky;
+ top: 1rem;
+ /* Bildet får all ledig plass, men aldri mindre enn dette. */
+ grid-template-rows: auto minmax(max(9rem, 24vh), 1fr) auto auto auto auto;
+ height: calc(100vh - 2rem);
+ /* Boksen skrumpes aldri: i lave vinduer er den like høy som innholdet. */
+ min-height: min-content;
+ }
+
+ /* Dobbel klasse, så regelen vinner over standardstilen lenger ned. */
+ .featured .featured-figure {
+ aspect-ratio: auto;
+ max-height: none;
+ }
+}
+
+/* ---------- Knapper ---------- */
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.65rem 1.1rem;
+ border: 2px solid var(--navy-900);
+ border-radius: 0.6rem;
+ background: var(--navy-900);
+ color: var(--eggshell);
+ font: inherit;
+ font-weight: 600;
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ transform 0.15s,
+ box-shadow 0.15s;
+}
+
+.button:hover:not(:disabled) {
+ background: var(--blue-300);
+ color: var(--navy-900);
+ transform: translateY(-2px);
+ box-shadow: 0 0.35rem 0.75rem rgb(10 34 64 / 0.2);
+}
+
+.button:active:not(:disabled) {
+ transform: translateY(0);
+ box-shadow: none;
+}
+
+.button:disabled {
+ opacity: 0.6;
+ cursor: progress;
+}
+
+.button-outline {
+ background: transparent;
+ color: var(--navy-900);
+}
+
+.button-outline:hover:not(:disabled) {
+ background: var(--navy-900);
+ color: var(--eggshell);
+}
+
+/* ---------- Fremhevet sted ---------- */
+
+.featured {
+ display: grid;
+ gap: 0.6rem;
+ padding: 1rem;
+ border: 1px solid var(--blue-100);
+ border-radius: 1rem;
+ background: var(--surface);
+ box-shadow: 0 0.5rem 1.5rem rgb(10 34 64 / 0.1);
+}
+
+.featured > p,
+.featured h2 {
+ margin: 0;
+}
+
+.featured-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.6rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid var(--blue-100);
+}
+
+.featured-header h2 {
+ margin-right: auto;
+ font-size: 1.6rem;
+ line-height: 1.2;
+}
+
+/* Lite kompass som lenker til stedets nettside. */
+.visit-link {
+ display: grid;
+ flex: none;
+ place-items: center;
+ width: 2rem;
+ height: 2rem;
+ border: 2px solid var(--navy-900);
+ border-radius: 50%;
+ color: var(--navy-900);
+ transition:
+ background 0.15s,
+ color 0.15s,
+ transform 0.15s;
+}
+
+.visit-link svg {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.visit-link:hover {
+ background: var(--navy-900);
+ color: var(--eggshell);
+ transform: scale(1.1);
+}
+
+.featured .rating {
+ flex: none;
+ margin: 0;
+ padding: 0.25rem 0.7rem;
+ border-radius: 999px;
+ background: var(--blue-100);
+ font-weight: 700;
+}
+
+.featured .rating small {
+ color: var(--muted);
+ font-weight: 400;
+}
+
+.featured-figure {
+ position: relative;
+ width: 100%;
+ margin: 0;
+ overflow: hidden;
+ border-radius: 0.75rem;
+ aspect-ratio: 16 / 10;
+ /* Bildet krymper på lave vinduer, så hele boksen er synlig. */
+ max-height: max(7rem, 24vh);
+}
+
+.type-badge {
+ position: absolute;
+ bottom: 0.75rem;
+ left: 0.75rem;
+ padding: 0.2rem 0.7rem;
+ border-radius: 0.4rem;
+ background: var(--navy-900);
+ color: var(--eggshell);
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.place-image {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.ph-sky {
+ fill: var(--blue-100);
+}
+
+.ph-sun {
+ fill: var(--eggshell);
+}
+
+.ph-house {
+ fill: var(--navy-700);
+}
+
+.ph-door {
+ fill: var(--eggshell);
+}
+
+.ph-water {
+ fill: var(--blue-300);
+}
+
+.facts {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.25rem 0.9rem;
+ margin: 0;
+}
+
+.facts dt {
+ color: var(--muted);
+ font-weight: 600;
+}
+
+.facts dd {
+ margin: 0;
+}
+
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.tags li {
+ padding: 0.15rem 0.7rem;
+ border-radius: 999px;
+ background: var(--blue-100);
+ font-size: 0.9rem;
+}
+
+.featured-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+}
+
+.featured-actions .button {
+ padding-block: 0.5rem;
+}
+
+/* ---------- Forslag ---------- */
+
+.suggestions h2 {
+ margin: 0 0 1rem;
+ font-size: 1.4rem;
+}
+
+.suggestion-list {
+ display: grid;
+ gap: 0.75rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.suggestion {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 9rem;
+ align-items: center;
+ gap: 1rem;
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid var(--blue-100);
+ border-radius: 0.8rem;
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s,
+ transform 0.15s;
+}
+
+.suggestion:hover {
+ border-color: var(--navy-500);
+ box-shadow: 0 0.5rem 1rem rgb(10 34 64 / 0.15);
+ transform: translateY(-2px);
+}
+
+.suggestion:active {
+ transform: translateY(0);
+}
+
+.suggestion-text {
+ display: grid;
+ gap: 0.15rem;
+}
+
+.suggestion-name {
+ font-size: 1.1rem;
+ font-weight: 700;
+}
+
+.suggestion-meta {
+ color: var(--muted);
+}
+
+.suggestion-hours {
+ font-weight: 600;
+}
+
+.suggestion .place-image {
+ aspect-ratio: 4 / 3;
+ border-radius: 0.5rem;
+}
+
+.suggestions-count {
+ margin: 1rem 0;
+ color: var(--muted);
+}
+
+@media (max-width: 30rem) {
+ .suggestion {
+ grid-template-columns: minmax(0, 1fr) 6rem;
+ }
+}
+
+/* Markerer stedet som vises som fremhevet. */
+.suggestion-active,
+.suggestion-active:hover {
+ border-color: var(--navy-500);
+ box-shadow: 0 0 0 3px var(--navy-500);
+}
+
+.list-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+/* ---------- Åpent / stengt ---------- */
+
+.status {
+ font-weight: 700;
+}
+
+.status-open {
+ color: var(--open);
+}
+
+.status-closed {
+ color: var(--closed);
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..922cfb1
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,87 @@
+import { useState } from 'react'
+import './App.css'
+import { FeaturedPlace } from './components/FeaturedPlace'
+import { PlaceList } from './components/PlaceList'
+import { SiteHeader } from './components/SiteHeader'
+import { useNow } from './hooks/useNow'
+import { PAGE_SIZE, usePlaces } from './hooks/usePlaces'
+import type { Place } from './types/place'
+
+function App() {
+ const now = useNow()
+ const {
+ places,
+ totalCount,
+ status,
+ hasMore,
+ canShowLess,
+ loadMore,
+ showLess,
+ } = usePlaces()
+
+ // Trekkes én gang per sidelasting, slik at det fremhevede stedet er tilfeldig.
+ const [startFraction] = useState(Math.random)
+ // Lagrer hele stedet, så det blir stående selv om «Vis mindre» fjerner det
+ // fra listen.
+ const [selected, setSelected] = useState(null)
+
+ const featured: Place | undefined =
+ selected ??
+ places[Math.floor(startFraction * Math.min(places.length, PAGE_SIZE))]
+
+ /** Blar til forrige (-1) eller neste (1) sted, og starter om i endene. */
+ const step = (direction: -1 | 1) => {
+ if (!featured) return
+ const index = places.findIndex((place) => place.id === featured.id)
+
+ // Er stedet skjult av «Vis mindre», starter vi i enden av listen.
+ const next =
+ index === -1
+ ? direction === 1
+ ? 0
+ : places.length - 1
+ : (index + direction + places.length) % places.length
+
+ setSelected(places[next])
+ }
+
+ return (
+ <>
+
+
+ {status === 'loading' && Laster steder …
}
+ {status === 'error' && (
+
+ Kunne ikke hente steder. Prøv å laste siden på nytt.
+
+ )}
+
+ {featured && (
+ <>
+ step(-1)}
+ onNext={() => step(1)}
+ />
+
+ >
+ )}
+
+ >
+ )
+}
+
+export default App
diff --git a/client/src/api/places.ts b/client/src/api/places.ts
new file mode 100644
index 0000000..4583c1e
--- /dev/null
+++ b/client/src/api/places.ts
@@ -0,0 +1,24 @@
+import { mockPlaces } from '../data/mockPlaces'
+import type { PlacesPage } from '../types/place'
+
+/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */
+const LATENCY_MS = 200
+
+/**
+ * Henter en side med utesteder.
+ *
+ * Dette er det eneste stedet appen henter data fra. Når GraphQL-backenden er
+ * klar, byttes innholdet her ut med et kall mot serveren (med samme signatur),
+ * og resten av appen trenger ikke endres.
+ */
+export async function fetchPlaces(
+ offset: number,
+ limit: number,
+): Promise {
+ await new Promise((resolve) => setTimeout(resolve, LATENCY_MS))
+
+ return {
+ items: mockPlaces.slice(offset, offset + limit),
+ totalCount: mockPlaces.length,
+ }
+}
diff --git a/client/src/assets/hero.png b/client/src/assets/hero.png
new file mode 100644
index 0000000..02251f4
Binary files /dev/null and b/client/src/assets/hero.png differ
diff --git a/client/src/assets/react.svg b/client/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/client/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/assets/vite.svg b/client/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/client/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/client/src/components/FeaturedPlace.tsx b/client/src/components/FeaturedPlace.tsx
new file mode 100644
index 0000000..02850cc
--- /dev/null
+++ b/client/src/components/FeaturedPlace.tsx
@@ -0,0 +1,106 @@
+import type { Place } from '../types/place'
+import { OpeningStatus } from './OpeningStatus'
+import { PlaceImage } from './PlaceImage'
+
+type FeaturedPlaceProps = {
+ place: Place
+ now: Date
+ onPrevious: () => void
+ onNext: () => void
+}
+
+/** Lite kompass som lenker til stedets nettside. */
+function CompassIcon() {
+ return (
+
+ )
+}
+
+/** Det fremhevede stedet, med knapper for å bla til forrige og neste. */
+export function FeaturedPlace({
+ place,
+ now,
+ onPrevious,
+ onNext,
+}: FeaturedPlaceProps) {
+ return (
+
+ {/* Gir skjermlesere beskjed når stedet byttes. */}
+
+ Fremhevet sted: {place.name}
+
+
+
+
+
+
+ {place.type}
+
+
+ {place.description}
+
+
+ - Åpningstid
+ -
+
+
+ - Område
+ - {place.area}
+ - Adresse
+ - {place.address}
+
+
+
+ {place.tags.map((tag) => (
+ - {tag}
+ ))}
+
+
+
+
+ )
+}
diff --git a/client/src/components/MainNav.tsx b/client/src/components/MainNav.tsx
new file mode 100644
index 0000000..5194486
--- /dev/null
+++ b/client/src/components/MainNav.tsx
@@ -0,0 +1,92 @@
+import type { ReactNode } from 'react'
+
+/** Felles tegning for ikonene: små, enkle strek-ikoner. */
+function Icon({ children }: { children: ReactNode }) {
+ return (
+
+ )
+}
+
+/** Foreløpige lenker. «Hjem» er siden vi står på. */
+const links: { label: string; icon: ReactNode; current?: boolean }[] = [
+ {
+ label: 'Utforsk',
+ // Kompass
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ label: 'Ruter',
+ // Ruten mellom to punkter
+ icon: (
+
+
+
+
+
+ ),
+ },
+ {
+ label: 'Hjem',
+ current: true,
+ // Anker
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ label: 'Utfordringer',
+ // Flagg
+ icon: (
+
+
+
+ ),
+ },
+ {
+ label: 'Bruker',
+ // Person
+ icon: (
+
+
+
+
+ ),
+ },
+]
+
+export function MainNav() {
+ return (
+
+ )
+}
diff --git a/client/src/components/OpeningStatus.tsx b/client/src/components/OpeningStatus.tsx
new file mode 100644
index 0000000..3f3db93
--- /dev/null
+++ b/client/src/components/OpeningStatus.tsx
@@ -0,0 +1,22 @@
+import { isOpenNow } from '../utils/openingHours'
+
+type OpeningStatusProps = {
+ openingHours: string
+ now: Date
+}
+
+/** «Åpent» i grønt eller «Stengt» i rødt, etterfulgt av åpningstiden. */
+export function OpeningStatus({ openingHours, now }: OpeningStatusProps) {
+ const isOpen = isOpenNow(openingHours, now)
+
+ if (isOpen === null) return <>{openingHours}>
+
+ return (
+ <>
+
+ {isOpen ? 'Åpent' : 'Stengt'}
+ {' '}
+ {openingHours}
+ >
+ )
+}
diff --git a/client/src/components/PlaceImage.tsx b/client/src/components/PlaceImage.tsx
new file mode 100644
index 0000000..b4a05c5
--- /dev/null
+++ b/client/src/components/PlaceImage.tsx
@@ -0,0 +1,34 @@
+import type { Place } from '../types/place'
+
+/**
+ * Stedets bilde. Mangler bildet, tegnes en liten SVG-plassholder i stedet,
+ * som er lettere å laste enn et rasterbilde.
+ */
+export function PlaceImage({ place }: { place: Place }) {
+ if (place.imageUrl) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/client/src/components/PlaceList.tsx b/client/src/components/PlaceList.tsx
new file mode 100644
index 0000000..4c1b3e3
--- /dev/null
+++ b/client/src/components/PlaceList.tsx
@@ -0,0 +1,93 @@
+import type { Place } from '../types/place'
+import { OpeningStatus } from './OpeningStatus'
+import { PlaceImage } from './PlaceImage'
+
+type PlaceListProps = {
+ places: Place[]
+ now: Date
+ featuredId: string
+ loadedCount: number
+ totalCount: number
+ hasMore: boolean
+ canShowLess: boolean
+ isLoadingMore: boolean
+ onSelect: (place: Place) => void
+ onLoadMore: () => void
+ onShowLess: () => void
+}
+
+/** Alle steder, på liggende kort under hverandre. */
+export function PlaceList({
+ places,
+ now,
+ featuredId,
+ loadedCount,
+ totalCount,
+ hasMore,
+ canShowLess,
+ isLoadingMore,
+ onSelect,
+ onLoadMore,
+ onShowLess,
+}: PlaceListProps) {
+ return (
+
+ Forslag
+
+
+ {places.map((place) => (
+ -
+
+
+ ))}
+
+
+
+ Viser {loadedCount} av {totalCount} steder
+
+
+
+
+ )
+}
diff --git a/client/src/components/ShipIcon.tsx b/client/src/components/ShipIcon.tsx
new file mode 100644
index 0000000..7c520e4
--- /dev/null
+++ b/client/src/components/ShipIcon.tsx
@@ -0,0 +1,20 @@
+/** Siluett av en seilbåt med to buede seil og et lavt skrog. */
+export function ShipIcon() {
+ return (
+
+ )
+}
diff --git a/client/src/components/SiteHeader.tsx b/client/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..cddcc61
--- /dev/null
+++ b/client/src/components/SiteHeader.tsx
@@ -0,0 +1,97 @@
+import { MainNav } from './MainNav'
+import { ShipIcon } from './ShipIcon'
+
+function MenuIcon() {
+ return (
+
+ )
+}
+
+export function SiteHeader() {
+ return (
+
+ )
+}
diff --git a/client/src/data/mockPlaces.ts b/client/src/data/mockPlaces.ts
new file mode 100644
index 0000000..4410e36
--- /dev/null
+++ b/client/src/data/mockPlaces.ts
@@ -0,0 +1,299 @@
+import type { Place } from '../types/place'
+
+type PlaceSeed = Omit
+
+/**
+ * Mockdata til utvikling. Stedene er oppdiktet og skal ikke forveksles med
+ * ekte utesteder i Trondheim. Erstattes av data fra backend senere.
+ */
+const seeds: PlaceSeed[] = [
+ {
+ name: 'Nidelven Pub',
+ type: 'Pub',
+ area: 'Bakklandet',
+ address: 'Eksempelgata 1, Trondheim',
+ openingHours: '15:00–01:00',
+ rating: 4.6,
+ reviewCount: 212,
+ description: 'Klassisk pub med lokale øl på tapp og sitteplasser ved elva.',
+ tags: ['uteservering', 'lokalt øl', 'rolig'],
+ },
+ {
+ name: 'Gløshaugen Bar',
+ type: 'Bar',
+ area: 'Gløshaugen',
+ address: 'Eksempelveien 12, Trondheim',
+ openingHours: '20:00–03:00',
+ rating: 4.1,
+ reviewCount: 340,
+ description: 'Studentvennlig bar med rimelige priser og quiz på onsdager.',
+ tags: ['studentpriser', 'quiz', 'livlig'],
+ },
+ {
+ name: 'Torget Vinbar',
+ type: 'Vinbar',
+ area: 'Midtbyen',
+ address: 'Eksempeltorget 3, Trondheim',
+ openingHours: '16:00–00:00',
+ rating: 4.8,
+ reviewCount: 96,
+ description: 'Intim vinbar med små retter og et bredt utvalg naturvin.',
+ tags: ['vin', 'rolig', 'date'],
+ },
+ {
+ name: 'Brygga Bryggeri',
+ type: 'Bryggeri',
+ area: 'Solsiden',
+ address: 'Eksempelkaia 7, Trondheim',
+ openingHours: '14:00–00:00',
+ rating: 4.5,
+ reviewCount: 158,
+ description: 'Mikrobryggeri med egenbrygget øl og omvisning i helgene.',
+ tags: ['lokalt øl', 'uteservering', 'mat'],
+ },
+ {
+ name: 'Skyline Cocktails',
+ type: 'Cocktailbar',
+ area: 'Midtbyen',
+ address: 'Eksempelgata 22, Trondheim',
+ openingHours: '19:00–02:00',
+ rating: 4.7,
+ reviewCount: 121,
+ description: 'Cocktailbar i toppetasjen med utsikt over byen.',
+ tags: ['cocktails', 'utsikt', 'date'],
+ },
+ {
+ name: 'Bakklandet Taphus',
+ type: 'Pub',
+ area: 'Bakklandet',
+ address: 'Eksempelsmuget 4, Trondheim',
+ openingHours: '16:00–00:30',
+ rating: 4.4,
+ reviewCount: 187,
+ description: 'Koselig taphus med mange sorter på fat og levende musikk.',
+ tags: ['livemusikk', 'lokalt øl', 'koselig'],
+ },
+ {
+ name: 'Solsiden Sjøbod',
+ type: 'Bar',
+ area: 'Solsiden',
+ address: 'Eksempelbryggen 2, Trondheim',
+ openingHours: '12:00–23:00',
+ rating: 4.2,
+ reviewCount: 264,
+ description: 'Avslappet bar på kaikanten med stor uteservering.',
+ tags: ['uteservering', 'sol', 'mat'],
+ },
+ {
+ name: 'Kjøpmannsgata Kjeller',
+ type: 'Bar',
+ area: 'Midtbyen',
+ address: 'Eksempelgata 15, Trondheim',
+ openingHours: '21:00–03:30',
+ rating: 3.9,
+ reviewCount: 305,
+ description: 'Kjellerbar med dansegulv og DJ fra torsdag til lørdag.',
+ tags: ['dans', 'DJ', 'livlig'],
+ },
+ {
+ name: 'Ila Vinstue',
+ type: 'Vinbar',
+ area: 'Ila',
+ address: 'Eksempelveien 8, Trondheim',
+ openingHours: '17:00–23:30',
+ rating: 4.6,
+ reviewCount: 74,
+ description: 'Lun vinstue i en gammel trebygning med ostebrett og tapas.',
+ tags: ['vin', 'mat', 'rolig'],
+ },
+ {
+ name: 'Midtbyen Malt',
+ type: 'Pub',
+ area: 'Midtbyen',
+ address: 'Eksempelplassen 5, Trondheim',
+ openingHours: '15:00–01:30',
+ rating: 4.3,
+ reviewCount: 143,
+ description: 'Whiskypub med over hundre typer malt og peiskos.',
+ tags: ['whisky', 'rolig', 'koselig'],
+ },
+ {
+ name: 'Fosen Fatøl',
+ type: 'Pub',
+ area: 'Nedre Elvehavn',
+ address: 'Eksempelhavna 9, Trondheim',
+ openingHours: '14:00–00:00',
+ rating: 4.0,
+ reviewCount: 98,
+ description: 'Trivelig pub med fokus på norsk fatøl og enkle småretter.',
+ tags: ['lokalt øl', 'mat', 'rolig'],
+ },
+ {
+ name: 'Tyholt Tappen',
+ type: 'Bar',
+ area: 'Tyholt',
+ address: 'Eksempelbakken 14, Trondheim',
+ openingHours: '17:00–00:00',
+ rating: 4.1,
+ reviewCount: 61,
+ description: 'Nabolagsbar med sportssendinger på storskjerm.',
+ tags: ['sport', 'storskjerm', 'avslappet'],
+ },
+ {
+ name: 'Lade Lounge',
+ type: 'Cocktailbar',
+ area: 'Lade',
+ address: 'Eksempelveien 40, Trondheim',
+ openingHours: '18:00–01:00',
+ rating: 4.3,
+ reviewCount: 82,
+ description: 'Stilfull lounge med signaturdrinker og rolig musikk.',
+ tags: ['cocktails', 'stilfull', 'date'],
+ },
+ {
+ name: 'Ravnkloa Bar',
+ type: 'Bar',
+ area: 'Midtbyen',
+ address: 'Eksempelkaia 1, Trondheim',
+ openingHours: '12:00–22:00',
+ rating: 4.0,
+ reviewCount: 176,
+ description: 'Sommerbar ved sjøen med utsikt mot fjorden.',
+ tags: ['uteservering', 'utsikt', 'sol'],
+ },
+ {
+ name: 'Munkholm Bryggeri',
+ type: 'Bryggeri',
+ area: 'Nedre Elvehavn',
+ address: 'Eksempelbrygga 3, Trondheim',
+ openingHours: '15:00–23:00',
+ rating: 4.7,
+ reviewCount: 233,
+ description: 'Moderne bryggeri med smaksprøver og sesongbrygg.',
+ tags: ['lokalt øl', 'smaksprøver', 'mat'],
+ },
+ {
+ name: 'Havnekanten Bar',
+ type: 'Bar',
+ area: 'Solsiden',
+ address: 'Eksempelkaia 11, Trondheim',
+ openingHours: '16:00–02:00',
+ rating: 4.2,
+ reviewCount: 129,
+ description: 'Livlig bar med drinkmeny og terrasse mot havna.',
+ tags: ['uteservering', 'cocktails', 'livlig'],
+ },
+ {
+ name: 'Trollheim Taproom',
+ type: 'Bryggeri',
+ area: 'Lade',
+ address: 'Eksempelveien 52, Trondheim',
+ openingHours: '16:00–23:00',
+ rating: 4.5,
+ reviewCount: 88,
+ description: 'Taproom med håndverksøl og brettspill på hyllene.',
+ tags: ['lokalt øl', 'brettspill', 'avslappet'],
+ },
+ {
+ name: 'Svartlamon Bar',
+ type: 'Bar',
+ area: 'Svartlamon',
+ address: 'Eksempelgata 30, Trondheim',
+ openingHours: '19:00–02:00',
+ rating: 4.4,
+ reviewCount: 154,
+ description: 'Alternativ bar med konserter og kunstutstillinger.',
+ tags: ['livemusikk', 'alternativ', 'kunst'],
+ },
+ {
+ name: 'Nordre Pub',
+ type: 'Pub',
+ area: 'Midtbyen',
+ address: 'Eksempelgata 6, Trondheim',
+ openingHours: '14:00–01:00',
+ rating: 3.8,
+ reviewCount: 201,
+ description: 'Tradisjonell pub med biljard og dart.',
+ tags: ['biljard', 'dart', 'studentpriser'],
+ },
+ {
+ name: 'Kongens Cocktailbar',
+ type: 'Cocktailbar',
+ area: 'Midtbyen',
+ address: 'Eksempelgata 18, Trondheim',
+ openingHours: '19:00–02:30',
+ rating: 4.9,
+ reviewCount: 67,
+ description: 'Prisbelønt cocktailbar med håndlaget sirup og infusjoner.',
+ tags: ['cocktails', 'stilfull', 'date'],
+ },
+ {
+ name: 'Elgeseter Bar',
+ type: 'Bar',
+ area: 'Elgeseter',
+ address: 'Eksempelveien 21, Trondheim',
+ openingHours: '18:00–01:00',
+ rating: 3.9,
+ reviewCount: 112,
+ description: 'Enkel studentbar med billig øl og spillkvelder.',
+ tags: ['studentpriser', 'spill', 'avslappet'],
+ },
+ {
+ name: 'Gamle Bybro Vinbar',
+ type: 'Vinbar',
+ area: 'Bakklandet',
+ address: 'Eksempelbrua 2, Trondheim',
+ openingHours: '16:00–00:00',
+ rating: 4.7,
+ reviewCount: 109,
+ description: 'Vinbar med utsikt mot bybroa og sesongbaserte småretter.',
+ tags: ['vin', 'utsikt', 'mat'],
+ },
+ {
+ name: 'Vollan Pub',
+ type: 'Pub',
+ area: 'Vollan',
+ address: 'Eksempelveien 3, Trondheim',
+ openingHours: '15:00–23:30',
+ rating: 4.1,
+ reviewCount: 58,
+ description: 'Rolig nabolagspub med god stemning og faste stamgjester.',
+ tags: ['rolig', 'koselig', 'lokalt øl'],
+ },
+ {
+ name: 'Nattergal Bar',
+ type: 'Bar',
+ area: 'Midtbyen',
+ address: 'Eksempelgata 27, Trondheim',
+ openingHours: '22:00–04:00',
+ rating: 4.0,
+ reviewCount: 290,
+ description: 'Nattåpen bar med dansegulv og sene åpningstider.',
+ tags: ['dans', 'DJ', 'nattåpen'],
+ },
+ {
+ name: 'Sjøgata Sidra',
+ type: 'Bryggeri',
+ area: 'Solsiden',
+ address: 'Eksempelkaia 14, Trondheim',
+ openingHours: '13:00–22:00',
+ rating: 4.3,
+ reviewCount: 71,
+ description: 'Siderprodusent med smaksbar og lokale ingredienser.',
+ tags: ['sider', 'smaksprøver', 'uteservering'],
+ },
+]
+
+/** Lager en URL-vennlig id fra navnet. */
+const toId = (name: string) =>
+ name
+ .toLocaleLowerCase('nb-NO')
+ .replaceAll(/[^a-z0-9æøå]+/g, '-')
+ .replaceAll(/^-|-$/g, '')
+
+export const mockPlaces: Place[] = seeds.map((seed) => ({
+ ...seed,
+ id: toId(seed.name),
+ websiteUrl: 'https://example.com',
+ imageUrl: null,
+}))
diff --git a/client/src/hooks/useNow.ts b/client/src/hooks/useNow.ts
new file mode 100644
index 0000000..9736243
--- /dev/null
+++ b/client/src/hooks/useNow.ts
@@ -0,0 +1,13 @@
+import { useEffect, useState } from 'react'
+
+/** Gir gjeldende tidspunkt og oppdaterer det jevnlig (hvert minutt som standard). */
+export function useNow(intervalMs = 60_000): Date {
+ const [now, setNow] = useState(() => new Date())
+
+ useEffect(() => {
+ const timer = setInterval(() => setNow(new Date()), intervalMs)
+ return () => clearInterval(timer)
+ }, [intervalMs])
+
+ return now
+}
diff --git a/client/src/hooks/usePlaces.ts b/client/src/hooks/usePlaces.ts
new file mode 100644
index 0000000..b5113ea
--- /dev/null
+++ b/client/src/hooks/usePlaces.ts
@@ -0,0 +1,67 @@
+import { useCallback, useEffect, useState } from 'react'
+import { fetchPlaces } from '../api/places'
+import type { Place } from '../types/place'
+
+/** Antall steder som hentes første gang og for hver trykk på «Vis mer». */
+export const PAGE_SIZE = 10
+
+type Status = 'loading' | 'idle' | 'loadingMore' | 'error'
+
+/** Henter utesteder og lar brukeren laste flere. */
+export function usePlaces() {
+ const [places, setPlaces] = useState([])
+ const [totalCount, setTotalCount] = useState(0)
+ const [status, setStatus] = useState('loading')
+
+ useEffect(() => {
+ let ignore = false
+
+ fetchPlaces(0, PAGE_SIZE)
+ .then((page) => {
+ if (ignore) return
+ setPlaces(page.items)
+ setTotalCount(page.totalCount)
+ setStatus('idle')
+ })
+ .catch(() => {
+ if (!ignore) setStatus('error')
+ })
+
+ return () => {
+ ignore = true
+ }
+ }, [])
+
+ const loadedCount = places.length
+
+ const loadMore = useCallback(async () => {
+ setStatus('loadingMore')
+
+ try {
+ const page = await fetchPlaces(loadedCount, PAGE_SIZE)
+ setPlaces((current) => [...current, ...page.items])
+ setTotalCount(page.totalCount)
+ setStatus('idle')
+ } catch {
+ setStatus('error')
+ }
+ }, [loadedCount])
+
+ /** Fjerner den sist lastede siden, men aldri under første side. */
+ const showLess = useCallback(() => {
+ setPlaces((current) => {
+ const pages = Math.ceil(current.length / PAGE_SIZE)
+ return current.slice(0, Math.max(1, pages - 1) * PAGE_SIZE)
+ })
+ }, [])
+
+ return {
+ places,
+ totalCount,
+ status,
+ hasMore: loadedCount < totalCount,
+ canShowLess: loadedCount > PAGE_SIZE,
+ loadMore,
+ showLess,
+ }
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..57b0f19
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,62 @@
+:root {
+ /* Eggeskall */
+ --eggshell: #f6f2e7;
+ --surface: #fffdf7;
+
+ /* Marineblått */
+ --navy-900: #0a2240;
+ --navy-700: #123a6b;
+ --navy-500: #2a5a96;
+
+ /* Lyseblått */
+ --blue-300: #8fb4dc;
+ --blue-100: #d9e8f7;
+
+ --text: var(--navy-900);
+ --muted: #4a5f7a;
+
+ /* Åpent og stengt */
+ --open: #17703a;
+ --closed: #b3261e;
+
+ color-scheme: light;
+ font-family:
+ system-ui,
+ -apple-system,
+ 'Segoe UI',
+ sans-serif;
+ line-height: 1.5;
+ color: var(--text);
+ background: var(--eggshell);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+:focus-visible {
+ outline: 3px solid var(--navy-500);
+ outline-offset: 2px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/client/src/types/place.ts b/client/src/types/place.ts
new file mode 100644
index 0000000..615f988
--- /dev/null
+++ b/client/src/types/place.ts
@@ -0,0 +1,24 @@
+export type VenueType = 'Bar' | 'Pub' | 'Bryggeri' | 'Vinbar' | 'Cocktailbar'
+
+export type Place = {
+ id: string
+ name: string
+ type: VenueType
+ area: string
+ address: string
+ /** Visningstekst, for eksempel «16:00–23:00». */
+ openingHours: string
+ rating: number
+ reviewCount: number
+ description: string
+ tags: string[]
+ websiteUrl: string
+ /** Null når stedet mangler bilde; da vises en lett plassholder. */
+ imageUrl: string | null
+}
+
+/** Én side med resultater fra API-et. */
+export type PlacesPage = {
+ items: Place[]
+ totalCount: number
+}
diff --git a/client/src/utils/openingHours.ts b/client/src/utils/openingHours.ts
new file mode 100644
index 0000000..4057791
--- /dev/null
+++ b/client/src/utils/openingHours.ts
@@ -0,0 +1,29 @@
+const HOURS_PATTERN = /^(\d{1,2}):(\d{2})\s*[–-]\s*(\d{1,2}):(\d{2})$/
+
+/**
+ * Sjekker om et sted er åpent på et gitt tidspunkt, ut fra en tekst som
+ * «15:00–01:00». Støtter åpningstider som går over midnatt.
+ *
+ * Returnerer `null` hvis teksten ikke kan tolkes, slik at grensesnittet kan
+ * la være å vise en status. Teksten har foreløpig ikke ukedager; det får en
+ * ekte backend ta høyde for.
+ */
+export function isOpenNow(openingHours: string, now: Date): boolean | null {
+ const match = HOURS_PATTERN.exec(openingHours.trim())
+ if (!match) return null
+
+ const [openHour, openMinute, closeHour, closeMinute] = match
+ .slice(1)
+ .map(Number)
+ const opens = openHour * 60 + openMinute
+ const closes = closeHour * 60 + closeMinute
+ const current = now.getHours() * 60 + now.getMinutes()
+
+ // Like start- og sluttid regnes som åpent hele døgnet.
+ if (opens === closes) return true
+
+ // Åpningstid over midnatt, for eksempel 22:00–04:00.
+ if (opens > closes) return current >= opens || current < closes
+
+ return current >= opens && current < closes
+}
diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json
new file mode 100644
index 0000000..6830b6f
--- /dev/null
+++ b/client/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
new file mode 100644
index 0000000..8455dcb
--- /dev/null
+++ b/client/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..9982072
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,7 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})