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..8cafc3d
--- /dev/null
+++ b/client/src/App.css
@@ -0,0 +1,712 @@
+/* ---------- Header ---------- */
+
+.site-header {
+ --header-end: #1b6a8c;
+
+ position: relative;
+ z-index: 5;
+ padding: 1.25rem 1rem 1.8rem;
+ background: linear-gradient(var(--navy-900), #0f3d68 45%, var(--header-end));
+ color: var(--eggshell);
+}
+
+.site-header :focus-visible {
+ outline-color: var(--blue-100);
+}
+
+/*
+ * Bølgekanten henger under toppen i samme farge som bunnen av den, så den
+ * passer mot alt som ligger under, både eggeskallbakgrunnen og kartet.
+ */
+.site-header::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 0;
+ left: 0;
+ height: 0.5rem;
+ background: var(--header-end);
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 8'%3E%3Cpath d='M0 4q12-8 24 0t24 0 24 0 24 0V0H0Z'/%3E%3C/svg%3E")
+ repeat-x top / 6rem 0.5rem;
+}
+
+/* Sidemargen holder tittelen unna hjørneknappene. */
+.site-title {
+ padding-inline: 3.25rem;
+ text-align: center;
+}
+
+/* Tittelen har fast høyde og én linje, så toppen er like høy på alle sider. */
+.site-title h1 {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+ height: 1.4em;
+ margin: 0;
+ font-size: clamp(2rem, 5vw, 2.85rem);
+ line-height: 1.4;
+}
+
+.site-title h1 > :first-child {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.site-title h1 > :last-child:not(:first-child) {
+ display: flex;
+ flex: none;
+}
+
+.site-title .avatar {
+ width: 1em;
+ height: 1em;
+ border-width: 2px;
+}
+
+/* På smale skjermer kan undertittelen gå over to linjer, og plassen er alltid satt av. */
+@media (max-width: 40rem) {
+ .site-title p {
+ min-height: 3em;
+ }
+}
+
+/* Tagline med tynne streker på hver side. */
+.site-title p {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+ margin: 0.4rem 0 0;
+ color: var(--blue-100);
+ font-size: clamp(0.95rem, 2.5vw, 1.2rem);
+}
+
+.site-title p::before,
+.site-title p::after {
+ content: '';
+ width: clamp(0.75rem, 5vw, 2.5rem);
+ 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;
+}
+
+/* Felles utseende for runde og avlange knapper over kartet. */
+.map-control {
+ display: inline-grid;
+ place-items: center;
+ border: 2px solid var(--navy-900);
+ background: var(--surface);
+ color: var(--navy-900);
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ transform 0.15s,
+ box-shadow 0.15s;
+}
+
+.map-control:hover:not(:disabled) {
+ background: var(--navy-900);
+ color: var(--eggshell);
+ transform: translateY(-2px);
+ box-shadow: 0 0.35rem 0.75rem rgb(10 34 64 / 0.25);
+}
+
+.map-control:active:not(:disabled) {
+ transform: translateY(0);
+ box-shadow: none;
+}
+
+.map-control:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+/* På den mørke headeren skal knappene ha lys kant og lys utheving. */
+.site-header .map-control {
+ border-color: var(--blue-300);
+}
+
+.site-header .map-control:hover:not(:disabled) {
+ background: var(--blue-300);
+ color: var(--navy-900);
+}
+
+/* ---------- Navigasjon ---------- */
+
+.main-nav ul {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(6.25rem, 1fr));
+ gap: 0.4rem;
+ max-width: 44rem;
+ margin: 0.85rem auto 0;
+ padding: 0;
+ list-style: none;
+}
+
+.main-nav a {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.3rem 0.55rem;
+ border-radius: 0.5rem;
+ background: rgb(10 34 64 / 0.6);
+ color: var(--eggshell);
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ transform 0.15s;
+}
+
+.nav-icon {
+ flex: none;
+ width: 1rem;
+ height: 1rem;
+}
+
+.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);
+}
+
+/* Innholdet tones inn når man bytter side, så overgangen blir myk. */
+@keyframes page-in {
+ from {
+ opacity: 0;
+ }
+}
+
+main {
+ animation: page-in 0.2s ease-out;
+}
+
+/* ---------- Bunn ---------- */
+
+/* Rotfeltet fyller vinduet, så sitatet havner nederst også på korte sider. */
+#root {
+ display: flex;
+ flex-direction: column;
+ min-height: 100dvh;
+}
+
+.site-footer {
+ margin-top: auto;
+ padding: 3.5rem 1.5rem 2.5rem;
+ color: #8f765a;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 0.85rem;
+ font-style: italic;
+ text-align: center;
+}
+
+.site-footer figure,
+.site-footer blockquote,
+.site-footer p {
+ margin: 0;
+}
+
+.site-footer figure {
+ max-width: 32rem;
+ margin-inline: auto;
+}
+
+.site-footer figcaption {
+ margin-top: 0.3rem;
+ font-size: 0.8rem;
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..1b135d9
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,36 @@
+import { useEffect } from 'react'
+import './App.css'
+import { PageFooter } from './components/PageFooter'
+import { PageHeader } from './components/PageHeader'
+import { useRoute } from './hooks/useRoute'
+import { ChallengesPage } from './pages/ChallengesPage'
+import { HomePage } from './pages/HomePage'
+import { MapPage } from './pages/MapPage'
+import { UserPage } from './pages/UserPage'
+
+const pages = {
+ home: HomePage,
+ map: MapPage,
+ user: UserPage,
+ challenges: ChallengesPage,
+}
+
+function App() {
+ const route = useRoute()
+ const Page = pages[route]
+
+ // Hver side starter øverst, ikke der forrige side var rullet til.
+ useEffect(() => {
+ window.scrollTo(0, 0)
+ }, [route])
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+export default App
diff --git a/client/src/api/challenges.ts b/client/src/api/challenges.ts
new file mode 100644
index 0000000..c248173
--- /dev/null
+++ b/client/src/api/challenges.ts
@@ -0,0 +1,17 @@
+import { mockChallenges } from '../data/mockChallenges'
+import type { Challenge } from '../types/challenge'
+
+/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */
+const LATENCY_MS = 200
+
+/**
+ * Henter alle utfordringene som kan trekkes på hjulet.
+ *
+ * Når GraphQL-backenden er klar, byttes innholdet her ut med et kall mot
+ * serveren (med samme signatur).
+ */
+export async function fetchChallenges(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, LATENCY_MS))
+
+ return mockChallenges
+}
diff --git a/client/src/api/places.ts b/client/src/api/places.ts
new file mode 100644
index 0000000..35789cd
--- /dev/null
+++ b/client/src/api/places.ts
@@ -0,0 +1,33 @@
+import { mockPlaces } from '../data/mockPlaces'
+import type { Place, 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,
+ }
+}
+
+/** Så mange steder hentes når en side trenger hele listen (kart og hjul). */
+const ALL_LIMIT = 500
+
+/** Henter alle steder. Byttes ut med egne spørringer når backenden er klar. */
+export async function fetchAllPlaces(): Promise {
+ const page = await fetchPlaces(0, ALL_LIMIT)
+ return page.items
+}
diff --git a/client/src/api/user.ts b/client/src/api/user.ts
new file mode 100644
index 0000000..55373c6
--- /dev/null
+++ b/client/src/api/user.ts
@@ -0,0 +1,17 @@
+import { mockUser } from '../data/mockUser'
+import type { User } from '../types/user'
+
+/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */
+const LATENCY_MS = 200
+
+/**
+ * Henter den innloggede brukeren.
+ *
+ * Når innlogging og GraphQL-backenden er på plass, byttes innholdet her ut med
+ * et kall mot serveren (med samme signatur).
+ */
+export async function fetchCurrentUser(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, LATENCY_MS))
+
+ return mockUser
+}
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 @@
+Vite
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}
+ ))}
+
+
+
+
+ Forrige
+
+
+ Neste
+
+
+
+ )
+}
diff --git a/client/src/components/Gulls.tsx b/client/src/components/Gulls.tsx
new file mode 100644
index 0000000..67abcb3
--- /dev/null
+++ b/client/src/components/Gulls.tsx
@@ -0,0 +1,35 @@
+/** Dekor: noen måker over havet, én gruppe på hver side av tittelen. */
+export function Gulls() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/client/src/components/HelpButton.tsx b/client/src/components/HelpButton.tsx
new file mode 100644
index 0000000..8602e03
--- /dev/null
+++ b/client/src/components/HelpButton.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from 'react'
+
+type HelpButtonProps = {
+ id: string
+ title: string
+ children: ReactNode
+}
+
+/** «?»-knappen øverst til høyre, som åpner en forklaring i en tekstboble. */
+export function HelpButton({ id, title, children }: HelpButtonProps) {
+ return (
+ <>
+
+ ?
+
+
+ {/* Åpnes og lukkes av nettleserens innebygde popover-støtte. */}
+
+ >
+ )
+}
diff --git a/client/src/components/MainNav.tsx b/client/src/components/MainNav.tsx
new file mode 100644
index 0000000..e2acfb4
--- /dev/null
+++ b/client/src/components/MainNav.tsx
@@ -0,0 +1,117 @@
+import type { ReactNode } from 'react'
+import {
+ CHALLENGES_HREF,
+ HOME_HREF,
+ MAP_HREF,
+ USER_HREF,
+ useRoute,
+} from '../hooks/useRoute'
+import type { Route } from '../hooks/useRoute'
+
+/** Felles tegning for ikonene: små, enkle strek-ikoner. */
+function Icon({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** Lenkene i hovednavigasjonen. «Ruter» har ingen side ennå. */
+const links: {
+ label: string
+ icon: ReactNode
+ href?: string
+ route?: Route
+}[] = [
+ {
+ label: 'Utforsk',
+ href: MAP_HREF,
+ route: 'map',
+ // Kompass
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ label: 'Ruter',
+ // Ruten mellom to punkter
+ icon: (
+
+
+
+
+
+ ),
+ },
+ {
+ label: 'Hjem',
+ href: HOME_HREF,
+ route: 'home',
+ // Anker
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ label: 'Utfordringer',
+ href: CHALLENGES_HREF,
+ route: 'challenges',
+ // Flagg
+ icon: (
+
+
+
+ ),
+ },
+ {
+ label: 'Bruker',
+ href: USER_HREF,
+ route: 'user',
+ // Person
+ icon: (
+
+
+
+
+ ),
+ },
+]
+
+export function MainNav() {
+ const current = useRoute()
+
+ return (
+
+
+
+ )
+}
diff --git a/client/src/components/MenuButton.tsx b/client/src/components/MenuButton.tsx
new file mode 100644
index 0000000..f99f5f0
--- /dev/null
+++ b/client/src/components/MenuButton.tsx
@@ -0,0 +1,16 @@
+/** Menyknappen øverst til venstre. Funksjonen kommer senere. */
+export function MenuButton() {
+ 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/PageFooter.tsx b/client/src/components/PageFooter.tsx
new file mode 100644
index 0000000..a7d32a5
--- /dev/null
+++ b/client/src/components/PageFooter.tsx
@@ -0,0 +1,37 @@
+import { useRoute } from '../hooks/useRoute'
+import type { Route } from '../hooks/useRoute'
+
+const quotes: Record = {
+ home: {
+ text: 'The sea, once it casts its spell, holds one in its net of wonder forever.',
+ author: 'Jacques-Yves Cousteau',
+ },
+ map: {
+ text: 'Man cannot discover new oceans unless he has the courage to lose sight of the shore.',
+ author: 'André Gide',
+ },
+ user: {
+ text: 'A sailor is an artist whose medium is the wind.',
+ author: 'Webb Chiles',
+ },
+ challenges: {
+ text: 'A smooth sea never made a skilled sailor.',
+ author: 'Franklin D. Roosevelt',
+ },
+}
+
+/** Et lite seilersitat nederst på hver side. */
+export function PageFooter() {
+ const { text, author } = quotes[useRoute()]
+
+ return (
+
+
+
+ “{text}”
+
+ – {author}
+
+
+ )
+}
diff --git a/client/src/components/PageHeader.tsx b/client/src/components/PageHeader.tsx
new file mode 100644
index 0000000..07e420f
--- /dev/null
+++ b/client/src/components/PageHeader.tsx
@@ -0,0 +1,71 @@
+import type { ReactNode } from 'react'
+import { useCurrentUser } from '../hooks/useCurrentUser'
+import { useRoute } from '../hooks/useRoute'
+import type { Route } from '../hooks/useRoute'
+import { ShipIcon } from './ShipIcon'
+import { SiteHeader } from './SiteHeader'
+import { Avatar } from './user/Avatar'
+
+type HeaderContent = {
+ title: string
+ subtitle: string
+ helpTitle: string
+ help: ReactNode
+}
+
+const content: Record = {
+ home: {
+ title: 'Tapsailor',
+ subtitle: 'Utforsk din neste reise',
+ helpTitle: 'Slik fungerer Tapsailor',
+ help: 'Tapsailor hjelper deg å finne barer, puber og andre utesteder i Trondheim. Stedet til venstre er et tilfeldig forslag som endres hver gang du laster inn siden. Bruk «Forrige» og «Neste» for å bla gjennom stedene, eller trykk på et sted i listen til høyre for å fremheve det. Trykk «Vis mer» nederst for å laste flere forslag, og «Vis mindre» for å skjule dem igjen. Under «Utforsk» ser du stedene på et kart.',
+ },
+ map: {
+ title: 'Utforsk',
+ subtitle: 'Finn utestedene i Trondheim på kartet',
+ helpTitle: 'Slik bruker du kartet',
+ help: 'Kartet viser utestedene i Trondheim som små skattemerker. Dra i kartet for å flytte deg, og bruk rulling, knappene nederst til høyre eller to fingre for å zoome. Søk etter et sted i feltet til venstre, så vises bare de stedene på kartet. Trykk på et sted i listen eller et merke på kartet for å se informasjon om stedet i ruten. Trykk på pilen øverst i ruten for å gå tilbake til søket. Foreløpig er dette et stilisert prøvekart, ikke et ekte kart.',
+ },
+ user: {
+ title: 'Profil',
+ subtitle: 'Din logg over kvelder til sjøs',
+ helpTitle: 'Slik bruker du profilen',
+ help: 'Dette er profilen din, med navn og profilbilde øverst. Under «Favorittsteder» samler du stedene du liker best, og har du mange, kan du bla sidelengs med pilene eller ved å dra i raden. «Favorittruter» og «Merker» fylles ut senere, når du kan lage egne ruter og fullføre utfordringer. Innlogging kommer også senere, så foreløpig vises en eksempelbruker.',
+ },
+ challenges: {
+ title: 'Prøv lykken',
+ subtitle: 'Rolig sjø har aldri skapt en dyktig sjømann',
+ helpTitle: 'Slik bruker du hjulene',
+ help: 'Her lar du tilfeldighetene bestemme kvelden. Velg øverst om du vil se begge hjulene, bare stedene eller bare utfordringene. Stedshjulet kan filtreres på type sted, og hjulet fylles med alle stedene som passer. Trykk på midten av et hjul, eller på «Spin»-knappen nederst, for å snurre, så åpnes stedet eller utfordringen i et lite vindu. Lukker du vinduet, åpner du det igjen ved å trykke på feltet under hjulet.',
+ },
+}
+
+/**
+ * Toppen står i appen, utenfor sidene, så den blir stående mens man blar
+ * mellom sidene. Bare teksten byttes.
+ */
+export function PageHeader() {
+ const route = useRoute()
+ const { user } = useCurrentUser()
+ const { title, subtitle, helpTitle, help } = content[route]
+
+ const isProfile = route === 'user' && user !== null
+ const icon =
+ route === 'home' ? (
+
+ ) : isProfile ? (
+
+ ) : undefined
+
+ return (
+
+ {help}
+
+ )
+}
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) => (
+
+ onSelect(place)}
+ >
+
+ {place.name}
+
+ {place.type} · {place.area}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ Viser {loadedCount} av {totalCount} steder
+
+
+
+ {hasMore && (
+
+ {isLoadingMore ? 'Laster …' : 'Vis mer'}
+
+ )}
+ {canShowLess && (
+
+ Vis mindre
+
+ )}
+
+
+ )
+}
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 (
+
+
+ {/* Storseil */}
+
+ {/* Fokk */}
+
+ {/* Skrog */}
+
+
+
+ )
+}
diff --git a/client/src/components/SiteHeader.tsx b/client/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..47431a8
--- /dev/null
+++ b/client/src/components/SiteHeader.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from 'react'
+import { Gulls } from './Gulls'
+import { HelpButton } from './HelpButton'
+import { MainNav } from './MainNav'
+import { MenuButton } from './MenuButton'
+
+type SiteHeaderProps = {
+ title: string
+ subtitle: string
+ /** Pynt ved siden av tittelen, for eksempel en seilbåt eller et profilbilde. */
+ icon?: ReactNode
+ helpId: string
+ helpTitle: string
+ /** Teksten i hjelpeboblen. */
+ children: ReactNode
+}
+
+/** Den blå toppen som alle sidene deler, med tittel og hovednavigasjon. */
+export function SiteHeader({
+ title,
+ subtitle,
+ icon,
+ helpId,
+ helpTitle,
+ children,
+}: SiteHeaderProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+
+ {title}
+ {icon && {icon} }
+
+ {subtitle}
+
+
+
+
+ )
+}
diff --git a/client/src/components/map/MapPanel.tsx b/client/src/components/map/MapPanel.tsx
new file mode 100644
index 0000000..8ec5f11
--- /dev/null
+++ b/client/src/components/map/MapPanel.tsx
@@ -0,0 +1,62 @@
+import type { KeyboardEvent, Ref } from 'react'
+import type { Place } from '../../types/place'
+import { PlaceDetails } from './PlaceDetails'
+import { PlaceSearch } from './PlaceSearch'
+
+type MapPanelProps = {
+ ref?: Ref
+ places: Place[]
+ selected: Place | null
+ query: string
+ now: Date
+ isLoading: boolean
+ onQueryChange: (query: string) => void
+ onSelect: (id: string) => void
+ onBack: () => void
+ onHover: (id: string | null) => void
+}
+
+/**
+ * Ruten til venstre. Den har fast størrelse og viser enten søk og forslag
+ * eller informasjon om det valgte stedet.
+ */
+export function MapPanel({
+ ref,
+ places,
+ selected,
+ query,
+ now,
+ isLoading,
+ onQueryChange,
+ onSelect,
+ onBack,
+ onHover,
+}: MapPanelProps) {
+ // Escape går tilbake til søket, som pilen øverst i ruten.
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && selected) onBack()
+ }
+
+ return (
+
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/client/src/components/map/MapViewport.tsx b/client/src/components/map/MapViewport.tsx
new file mode 100644
index 0000000..d036d1a
--- /dev/null
+++ b/client/src/components/map/MapViewport.tsx
@@ -0,0 +1,411 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { KeyboardEvent, PointerEvent } from 'react'
+import type { Place } from '../../types/place'
+import { MAP_HEIGHT, MAP_WIDTH, toMapPoint } from '../../utils/mapProjection'
+import { PlacePin } from './PlacePin'
+import { StylizedMap } from './StylizedMap'
+
+/** Kartets plassering på skjermen: forskyvning i piksler og zoomnivå. */
+type View = { x: number; y: number; k: number }
+type Size = { width: number; height: number }
+/** Plass som er dekket av ruten, så kartet ikke skjules bak den. */
+export type Inset = { left: number; bottom: number }
+
+type MapViewportProps = {
+ places: Place[]
+ selectedId: string | null
+ hoveredId: string | null
+ inset: Inset
+ onSelect: (id: string) => void
+}
+
+const MAX_ZOOM_FACTOR = 8
+const BUTTON_ZOOM = 1.5
+const DRAG_THRESHOLD = 4
+const KEY_PAN = 80
+const FLY_MS = 400
+
+/** Minste zoom: kartet skal alltid fylle det synlige området. */
+const minScale = (size: Size, inset: Inset) =>
+ Math.max((size.width - inset.left) / MAP_WIDTH, size.height / MAP_HEIGHT)
+
+/** Holder kartet innenfor kantene og zoomen innenfor grensene. */
+function clampView(view: View, size: Size, inset: Inset): View {
+ const min = minScale(size, inset)
+ const k = Math.min(Math.max(view.k, min), min * MAX_ZOOM_FACTOR)
+
+ return {
+ k,
+ x: Math.min(inset.left, Math.max(size.width - MAP_WIDTH * k, view.x)),
+ y: Math.min(0, Math.max(size.height - MAP_HEIGHT * k, view.y)),
+ }
+}
+
+/** Zoomer med punktet (px, py) som fast punkt. */
+function zoomView(
+ view: View,
+ factor: number,
+ px: number,
+ py: number,
+ size: Size,
+ inset: Inset,
+): View {
+ const min = minScale(size, inset)
+ const k = Math.min(Math.max(view.k * factor, min), min * MAX_ZOOM_FACTOR)
+ const ratio = k / view.k
+
+ return clampView(
+ { k, x: px - (px - view.x) * ratio, y: py - (py - view.y) * ratio },
+ size,
+ inset,
+ )
+}
+
+const prefersReducedMotion = () =>
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+/** Kartflaten med dra, zoom og merker for hvert sted. */
+export function MapViewport({
+ places,
+ selectedId,
+ hoveredId,
+ inset,
+ onSelect,
+}: MapViewportProps) {
+ const sectionRef = useRef(null)
+ const [size, setSize] = useState(null)
+ const [view, setView] = useState(null)
+
+ const viewRef = useRef(null)
+ const animationRef = useRef(0)
+ const pointersRef = useRef(new Map())
+ const gestureRef = useRef({
+ originX: 0,
+ originY: 0,
+ isDragging: false,
+ pinchDistance: 0,
+ })
+
+ useEffect(() => {
+ viewRef.current = view
+ }, [view])
+
+ useEffect(() => {
+ const section = sectionRef.current
+ if (!section) return
+
+ const update = () =>
+ setSize({ width: section.clientWidth, height: section.clientHeight })
+ update()
+
+ const observer = new ResizeObserver(update)
+ observer.observe(section)
+ return () => observer.disconnect()
+ }, [])
+
+ // Viser hele kartet første gang, og holder det innenfor kantene senere.
+ useEffect(() => {
+ if (!size) return
+
+ setView((previous) => {
+ if (previous) return clampView(previous, size, inset)
+
+ const k = minScale(size, inset)
+ return clampView(
+ {
+ k,
+ x: inset.left + (size.width - inset.left - MAP_WIDTH * k) / 2,
+ y: (size.height - MAP_HEIGHT * k) / 2,
+ },
+ size,
+ inset,
+ )
+ })
+ }, [size, inset])
+
+ const stopFlying = useCallback(
+ () => cancelAnimationFrame(animationRef.current),
+ [],
+ )
+
+ /** Glir mykt til en ny visning. */
+ const flyTo = useCallback(
+ (target: View) => {
+ stopFlying()
+ const from = viewRef.current
+
+ if (!from || prefersReducedMotion()) {
+ setView(target)
+ return
+ }
+
+ const start = performance.now()
+ const step = (now: number) => {
+ const t = Math.min(1, (now - start) / FLY_MS)
+ const eased = 1 - (1 - t) ** 3
+
+ setView({
+ k: from.k + (target.k - from.k) * eased,
+ x: from.x + (target.x - from.x) * eased,
+ y: from.y + (target.y - from.y) * eased,
+ })
+ if (t < 1) animationRef.current = requestAnimationFrame(step)
+ }
+ animationRef.current = requestAnimationFrame(step)
+ },
+ [stopFlying],
+ )
+
+ useEffect(() => stopFlying, [stopFlying])
+
+ // Sentrerer kartet på det valgte stedet, midt i det området ruten ikke dekker.
+ useEffect(() => {
+ const current = viewRef.current
+ const place = places.find(({ id }) => id === selectedId)
+ if (!current || !size || !place) return
+
+ const { x, y } = toMapPoint(place.latitude, place.longitude)
+ const k = Math.max(current.k, minScale(size, inset) * 2.5)
+ const centerX = inset.left + (size.width - inset.left) / 2
+ // Merkets spiss er under sentrum; løft kartet litt så hele merket synes.
+ const centerY = (size.height - inset.bottom) / 2 + 24
+
+ flyTo(clampView({ k, x: centerX - x * k, y: centerY - y * k }, size, inset))
+ // Skal bare sentrere når et nytt sted velges, ikke når størrelsen endres.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedId])
+
+ const zoomBy = useCallback(
+ (factor: number, px: number, py: number) => {
+ if (!size) return
+ stopFlying()
+ setView((previous) =>
+ previous ? zoomView(previous, factor, px, py, size, inset) : previous,
+ )
+ },
+ [size, inset, stopFlying],
+ )
+
+ // Må registreres direkte for å kunne hindre at hele siden ruller.
+ useEffect(() => {
+ const section = sectionRef.current
+ if (!section) return
+
+ const handleWheel = (event: WheelEvent) => {
+ event.preventDefault()
+ const rect = section.getBoundingClientRect()
+ const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
+ zoomBy(
+ Math.exp(-delta * 0.002),
+ event.clientX - rect.left,
+ event.clientY - rect.top,
+ )
+ }
+
+ section.addEventListener('wheel', handleWheel, { passive: false })
+ return () => section.removeEventListener('wheel', handleWheel)
+ }, [zoomBy])
+
+ const handlePointerDown = (event: PointerEvent) => {
+ stopFlying()
+ pointersRef.current.set(event.pointerId, {
+ x: event.clientX,
+ y: event.clientY,
+ })
+ gestureRef.current = {
+ originX: event.clientX,
+ originY: event.clientY,
+ isDragging: false,
+ pinchDistance: 0,
+ }
+ }
+
+ const handlePointerMove = (event: PointerEvent) => {
+ const pointers = pointersRef.current
+ const previous = pointers.get(event.pointerId)
+ if (!previous || !size) return
+
+ const gesture = gestureRef.current
+ const point = { x: event.clientX, y: event.clientY }
+ const target = event.currentTarget
+
+ if (pointers.size === 1) {
+ // Små bevegelser regnes som et trykk, slik at merkene kan trykkes på.
+ if (
+ !gesture.isDragging &&
+ Math.hypot(point.x - gesture.originX, point.y - gesture.originY) <
+ DRAG_THRESHOLD
+ ) {
+ return
+ }
+
+ gesture.isDragging = true
+ if (!target.hasPointerCapture(event.pointerId)) {
+ target.setPointerCapture(event.pointerId)
+ }
+ pointers.set(event.pointerId, point)
+
+ const dx = point.x - previous.x
+ const dy = point.y - previous.y
+ setView((current) =>
+ current
+ ? clampView(
+ { ...current, x: current.x + dx, y: current.y + dy },
+ size,
+ inset,
+ )
+ : current,
+ )
+ return
+ }
+
+ // To fingre: zoom etter hvor langt de er fra hverandre.
+ pointers.set(event.pointerId, point)
+ const [a, b] = [...pointers.values()]
+ const distance = Math.hypot(a.x - b.x, a.y - b.y)
+ const rect = target.getBoundingClientRect()
+
+ gesture.isDragging = true
+ if (!target.hasPointerCapture(event.pointerId)) {
+ target.setPointerCapture(event.pointerId)
+ }
+ if (gesture.pinchDistance > 0) {
+ zoomBy(
+ distance / gesture.pinchDistance,
+ (a.x + b.x) / 2 - rect.left,
+ (a.y + b.y) / 2 - rect.top,
+ )
+ }
+ gesture.pinchDistance = distance
+ }
+
+ const handlePointerEnd = (event: PointerEvent) => {
+ pointersRef.current.delete(event.pointerId)
+ gestureRef.current.pinchDistance = 0
+ }
+
+ const zoomFromCenter = (factor: number) => {
+ if (size) zoomBy(factor, size.width / 2, size.height / 2)
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Merkene håndterer sine egne taster.
+ if (event.target !== event.currentTarget || !size) return
+
+ const pan = (dx: number, dy: number) => {
+ event.preventDefault()
+ stopFlying()
+ setView((current) =>
+ current
+ ? clampView(
+ { ...current, x: current.x + dx, y: current.y + dy },
+ size,
+ inset,
+ )
+ : current,
+ )
+ }
+
+ switch (event.key) {
+ case 'ArrowLeft':
+ return pan(KEY_PAN, 0)
+ case 'ArrowRight':
+ return pan(-KEY_PAN, 0)
+ case 'ArrowUp':
+ return pan(0, KEY_PAN)
+ case 'ArrowDown':
+ return pan(0, -KEY_PAN)
+ case '+':
+ case '=':
+ event.preventDefault()
+ return zoomFromCenter(BUTTON_ZOOM)
+ case '-':
+ event.preventDefault()
+ return zoomFromCenter(1 / BUTTON_ZOOM)
+ }
+ }
+
+ // Det valgte og det utpekte merket tegnes sist, så de ligger øverst.
+ const orderedPlaces = useMemo(() => {
+ const rank = (id: string) =>
+ id === selectedId ? 2 : id === hoveredId ? 1 : 0
+ return [...places].sort((a, b) => rank(a.id) - rank(b.id))
+ }, [places, selectedId, hoveredId])
+
+ return (
+
+
+ {view && (
+ <>
+
+
+
+ {orderedPlaces.map((place) => {
+ const point = toMapPoint(place.latitude, place.longitude)
+ return (
+ onSelect(place.id)}
+ />
+ )
+ })}
+ >
+ )}
+
+
+
+
+
+
+
+ N
+
+
+
+
+
+ zoomFromCenter(BUTTON_ZOOM)}
+ >
+ +
+
+
+
+ zoomFromCenter(1 / BUTTON_ZOOM)}
+ >
+ −
+
+
+
+
+ )
+}
diff --git a/client/src/components/map/PlaceDetails.tsx b/client/src/components/map/PlaceDetails.tsx
new file mode 100644
index 0000000..d024c82
--- /dev/null
+++ b/client/src/components/map/PlaceDetails.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef } from 'react'
+import type { Place } from '../../types/place'
+import { OpeningStatus } from '../OpeningStatus'
+import { PlaceImage } from '../PlaceImage'
+
+type PlaceDetailsProps = {
+ place: Place
+ now: Date
+ onBack: () => void
+}
+
+/** Alt om ett sted, med en pil som går tilbake til søket. */
+export function PlaceDetails({ place, now, onBack }: PlaceDetailsProps) {
+ const headingRef = useRef(null)
+
+ // Flytter fokus hit, så skjermlesere får lest opp stedet som ble valgt.
+ useEffect(() => {
+ headingRef.current?.focus()
+ }, [place.id])
+
+ return (
+
+
+
+
+
+ {place.type}
+
+
+ {place.description}
+
+
+ Åpningstid
+
+
+
+ Område
+ {place.area}
+ Adresse
+ {place.address}
+
+
+
+ {place.tags.map((tag) => (
+ {tag}
+ ))}
+
+
+
+ Besøk nettside
+ (åpnes i ny fane)
+
+
+ )
+}
diff --git a/client/src/components/map/PlacePin.tsx b/client/src/components/map/PlacePin.tsx
new file mode 100644
index 0000000..8f31d93
--- /dev/null
+++ b/client/src/components/map/PlacePin.tsx
@@ -0,0 +1,63 @@
+import type { KeyboardEvent } from 'react'
+import type { Place } from '../../types/place'
+
+type PlacePinProps = {
+ place: Place
+ /** Plassering i skjermpiksler, der spissen på merket peker. */
+ x: number
+ y: number
+ isSelected: boolean
+ isHovered: boolean
+ onSelect: () => void
+}
+
+/** Et «X marks the spot»-merke som markerer ett utested på kartet. */
+export function PlacePin({
+ place,
+ x,
+ y,
+ isSelected,
+ isHovered,
+ onSelect,
+}: PlacePinProps) {
+ const className = [
+ 'map-pin',
+ isSelected && 'map-pin-selected',
+ isHovered && 'map-pin-hover',
+ ]
+ .filter(Boolean)
+ .join(' ')
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onSelect()
+ }
+ }
+
+ return (
+
+ {place.name}
+
+
+
+
+
+
+ {place.name}
+
+
+ )
+}
diff --git a/client/src/components/map/PlaceSearch.tsx b/client/src/components/map/PlaceSearch.tsx
new file mode 100644
index 0000000..b111189
--- /dev/null
+++ b/client/src/components/map/PlaceSearch.tsx
@@ -0,0 +1,82 @@
+import type { Place } from '../../types/place'
+import { OpeningStatus } from '../OpeningStatus'
+
+type PlaceSearchProps = {
+ places: Place[]
+ query: string
+ now: Date
+ isLoading: boolean
+ onQueryChange: (query: string) => void
+ onSelect: (id: string) => void
+ onHover: (id: string | null) => void
+}
+
+/** Søkefeltet og listen med steder i panelet. */
+export function PlaceSearch({
+ places,
+ query,
+ now,
+ isLoading,
+ onQueryChange,
+ onSelect,
+ onHover,
+}: PlaceSearchProps) {
+ const summary = isLoading ? 'Laster steder …' : `${places.length} steder`
+
+ return (
+ <>
+
+
+
+ {summary}
+
+
+ {places.length === 0 ? (
+
+ {isLoading ? 'Laster steder …' : 'Ingen steder passer søket.'}
+
+ ) : (
+
+ {places.map((place) => (
+
+ onSelect(place.id)}
+ onMouseEnter={() => onHover(place.id)}
+ onMouseLeave={() => onHover(null)}
+ onFocus={() => onHover(place.id)}
+ onBlur={() => onHover(null)}
+ >
+ {place.name}
+
+ {place.type} · {place.area}
+
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ )
+}
diff --git a/client/src/components/map/StylizedMap.tsx b/client/src/components/map/StylizedMap.tsx
new file mode 100644
index 0000000..e0f6a42
--- /dev/null
+++ b/client/src/components/map/StylizedMap.tsx
@@ -0,0 +1,132 @@
+import { memo } from 'react'
+import { MAP_HEIGHT, MAP_WIDTH } from '../../utils/mapProjection'
+
+/** Elva og fjorden er tegnet for hånd; alt annet er enkle former. */
+const COAST =
+ 'M-700-500H1700V110C1500 140 1350 90 1100 120S800 150 650 150 450 120 330 145 100 110-100 140-500 130-700 150Z'
+const RIVER =
+ 'M790 920 760 800C740 640 690 560 600 520S470 400 520 330 640 250 650 150'
+
+const ROADS = [
+ 'M-100 330C150 300 300 360 520 330S850 280 1100 300',
+ 'M330 160C340 300 300 480 360 640S420 800 440 900',
+ 'M700 160C760 300 820 420 900 520S960 700 1000 900',
+ 'M-100 560C200 560 400 620 600 520M600 600C700 590 800 620 1100 560',
+ 'M150 150C180 300 200 420 150 520S120 700 100 900',
+ 'M520 330C560 400 640 380 700 440S760 600 770 800',
+]
+
+/** Små hus: x, y, bredde, høyde. */
+const BUILDINGS = [
+ [250, 260, 22, 16],
+ [285, 262, 16, 22],
+ [400, 340, 24, 18],
+ [430, 370, 18, 24],
+ [630, 300, 22, 16],
+ [660, 330, 16, 20],
+ [720, 380, 24, 18],
+ [800, 300, 18, 22],
+ [870, 380, 22, 16],
+ [230, 470, 24, 18],
+ [260, 500, 18, 22],
+ [330, 560, 22, 16],
+ [420, 660, 24, 18],
+ [540, 640, 18, 22],
+ [620, 700, 22, 16],
+ [790, 680, 24, 18],
+ [860, 590, 18, 22],
+ [940, 470, 22, 16],
+ [150, 420, 22, 16],
+ [310, 230, 20, 14],
+]
+
+const WAVES = [
+ [120, 70],
+ [260, 40],
+ [420, 85],
+ [560, 50],
+ [760, 75],
+ [900, 40],
+ [40, 100],
+ [690, 30],
+]
+
+/** Rutenett som i et sjøkart, med en strek for hver 200. enhet. */
+const GRID = Array.from({ length: 4 }, (_, index) => {
+ const offset = (index + 1) * 200
+ return `M${offset} -500V1300M-700 ${offset}H1700`
+}).join('')
+
+/** Tegner et stilisert kart over Trondheim. Det er ikke et ekte kart. */
+export const StylizedMap = memo(function StylizedMap() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {WAVES.map(([x, y]) => (
+
+ ))}
+
+
+
+
+ {ROADS.map((road) => (
+
+
+
+
+ ))}
+
+ {BUILDINGS.map(([x, y, width, height]) => (
+
+ ))}
+
+
+ TRONDHEIMSFJORDEN
+
+
+ Nidelva
+
+
+ Stilisert kart · {MAP_WIDTH} × {MAP_HEIGHT}
+
+ >
+ )
+})
diff --git a/client/src/components/user/Avatar.tsx b/client/src/components/user/Avatar.tsx
new file mode 100644
index 0000000..9de3d83
--- /dev/null
+++ b/client/src/components/user/Avatar.tsx
@@ -0,0 +1,27 @@
+type AvatarProps = {
+ name: string
+ url: string | null
+}
+
+/** Profilbildet, eller en enkel plassholder når bildet mangler. */
+export function Avatar({ name, url }: AvatarProps) {
+ if (url) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/user/BadgeSlots.tsx b/client/src/components/user/BadgeSlots.tsx
new file mode 100644
index 0000000..a7dfddd
--- /dev/null
+++ b/client/src/components/user/BadgeSlots.tsx
@@ -0,0 +1,21 @@
+const SLOTS = [0, 1, 2, 3, 4]
+
+/** Tomme plasser for badges. Selve badgene kommer når utfordringene er laget. */
+export function BadgeSlots() {
+ return (
+ <>
+
+ {SLOTS.map((slot) => (
+
+
+
+
+
+ ))}
+
+
+ Merker får du ved å fullføre utfordringer. De dukker opp her.
+
+ >
+ )
+}
diff --git a/client/src/components/user/CarouselSection.tsx b/client/src/components/user/CarouselSection.tsx
new file mode 100644
index 0000000..e400b9d
--- /dev/null
+++ b/client/src/components/user/CarouselSection.tsx
@@ -0,0 +1,130 @@
+import { Children, useCallback, useEffect, useRef, useState } from 'react'
+import type { ReactNode } from 'react'
+import { ProfileSection } from './ProfileSection'
+
+type CarouselSectionProps = {
+ id: string
+ title: string
+ icon: ReactNode
+ /** Vises når seksjonen ikke har noe innhold ennå. */
+ emptyText: string
+ /** Ett ``-element per kort. */
+ children: ReactNode
+}
+
+function Chevron({ direction }: { direction: 'left' | 'right' }) {
+ return (
+
+
+
+ )
+}
+
+const prefersReducedMotion = () =>
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+/**
+ * En seksjon med kort på én rad. Er det flere kort enn det er plass til, vises
+ * piler, og raden kan også dras eller rulles sidelengs.
+ */
+export function CarouselSection({
+ id,
+ title,
+ icon,
+ emptyText,
+ children,
+}: CarouselSectionProps) {
+ const listRef = useRef(null)
+ const [scroll, setScroll] = useState({
+ hasOverflow: false,
+ atStart: true,
+ atEnd: true,
+ })
+ const count = Children.count(children)
+
+ const update = useCallback(() => {
+ const list = listRef.current
+ if (!list) return
+
+ setScroll({
+ hasOverflow: list.scrollWidth > list.clientWidth + 1,
+ atStart: list.scrollLeft <= 1,
+ atEnd: list.scrollLeft + list.clientWidth >= list.scrollWidth - 1,
+ })
+ }, [])
+
+ useEffect(() => {
+ const list = listRef.current
+ if (!list) return
+
+ update()
+ const observer = new ResizeObserver(update)
+ observer.observe(list)
+ return () => observer.disconnect()
+ }, [update, count])
+
+ const scrollByPage = (direction: -1 | 1) => {
+ const list = listRef.current
+ list?.scrollBy({
+ left: direction * list.clientWidth,
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
+ })
+ }
+
+ const controls = scroll.hasOverflow && (
+
+
+ scrollByPage(-1)}
+ >
+
+
+
+
+ scrollByPage(1)}
+ >
+
+
+
+
+ )
+
+ return (
+
+ {count === 0 ? (
+ {emptyText}
+ ) : (
+ // Fokuserbar, så raden kan rulles med piltastene uten mus.
+
+ )}
+
+ )
+}
diff --git a/client/src/components/user/FavouritePlaceCard.tsx b/client/src/components/user/FavouritePlaceCard.tsx
new file mode 100644
index 0000000..e35f1cf
--- /dev/null
+++ b/client/src/components/user/FavouritePlaceCard.tsx
@@ -0,0 +1,23 @@
+import type { Place } from '../../types/place'
+import { PlaceImage } from '../PlaceImage'
+
+/** Et lite kort for et favorittsted. */
+export function FavouritePlaceCard({ place }: { place: Place }) {
+ return (
+
+
+ {place.name}
+
+ {place.type} · {place.area}
+
+
+ Vurdering:
+ ★ {place.rating.toFixed(1)}
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/user/ProfileSection.tsx b/client/src/components/user/ProfileSection.tsx
new file mode 100644
index 0000000..08238e9
--- /dev/null
+++ b/client/src/components/user/ProfileSection.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react'
+
+type ProfileSectionProps = {
+ id: string
+ title: string
+ icon: ReactNode
+ /** Knapper til høyre for overskriften. */
+ actions?: ReactNode
+ children: ReactNode
+}
+
+/** En seksjon på profilsiden, med ikon og overskrift. */
+export function ProfileSection({
+ id,
+ title,
+ icon,
+ actions,
+ children,
+}: ProfileSectionProps) {
+ return (
+
+
+
+ {icon}
+ {title}
+
+ {actions}
+
+ {children}
+
+ )
+}
diff --git a/client/src/components/wheel/ChallengeResult.tsx b/client/src/components/wheel/ChallengeResult.tsx
new file mode 100644
index 0000000..7b2ee8d
--- /dev/null
+++ b/client/src/components/wheel/ChallengeResult.tsx
@@ -0,0 +1,10 @@
+import type { Challenge } from '../../types/challenge'
+
+/** Utfordringen hjulet stoppet på. */
+export function ChallengeResult({ challenge }: { challenge: Challenge }) {
+ return (
+
+ {challenge.description}
+
+ )
+}
diff --git a/client/src/components/wheel/CombinedResult.tsx b/client/src/components/wheel/CombinedResult.tsx
new file mode 100644
index 0000000..05c0715
--- /dev/null
+++ b/client/src/components/wheel/CombinedResult.tsx
@@ -0,0 +1,24 @@
+import type { Challenge } from '../../types/challenge'
+import type { Place } from '../../types/place'
+import { ChallengeResult } from './ChallengeResult'
+import { PlaceResult } from './PlaceResult'
+
+type CombinedResultProps = { place: Place; challenge: Challenge; now: Date }
+
+/** Sted og utfordring side om side, når begge hjulene er snurret samtidig. */
+export function CombinedResult({ place, challenge, now }: CombinedResultProps) {
+ return (
+
+
+ Sted
+ {place.name}
+
+
+
+ Utfordring
+ {challenge.title}
+
+
+
+ )
+}
diff --git a/client/src/components/wheel/PlaceResult.tsx b/client/src/components/wheel/PlaceResult.tsx
new file mode 100644
index 0000000..c005aa7
--- /dev/null
+++ b/client/src/components/wheel/PlaceResult.tsx
@@ -0,0 +1,28 @@
+import type { Place } from '../../types/place'
+import { OpeningStatus } from '../OpeningStatus'
+import { PlaceImage } from '../PlaceImage'
+
+/** Informasjon om stedet hjulet stoppet på. */
+export function PlaceResult({ place, now }: { place: Place; now: Date }) {
+ return (
+
+
+
+ {place.type}
+
+
+ {place.description}
+
+
+ Åpningstid
+
+
+
+ Område
+ {place.area}
+ Adresse
+ {place.address}
+
+
+ )
+}
diff --git a/client/src/components/wheel/ResultDialog.tsx b/client/src/components/wheel/ResultDialog.tsx
new file mode 100644
index 0000000..8820db7
--- /dev/null
+++ b/client/src/components/wheel/ResultDialog.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useId, useRef } from 'react'
+import type { ReactNode } from 'react'
+
+type ResultDialogProps = {
+ isOpen: boolean
+ title: string
+ /** Skjuler overskriften, når innholdet har egne. */
+ hideTitle?: boolean
+ wide?: boolean
+ onClose: () => void
+ children: ReactNode
+}
+
+/** Et lite vindu med resultatet fra hjulet. Esc og X-knappen lukker det. */
+export function ResultDialog({
+ isOpen,
+ title,
+ hideTitle,
+ wide,
+ onClose,
+ children,
+}: ResultDialogProps) {
+ const dialogRef = useRef(null)
+ const titleId = useId()
+
+ // Nettleserens egen dialog tar seg av fokus, Esc og mørk bakgrunn.
+ useEffect(() => {
+ const dialog = dialogRef.current
+ if (!dialog) return
+
+ if (isOpen && !dialog.open) dialog.showModal()
+ else if (!isOpen && dialog.open) dialog.close()
+ }, [isOpen])
+
+ return (
+ {
+ if (event.target === event.currentTarget) onClose()
+ }}
+ >
+
+
+ {title}
+
+
+
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/client/src/components/wheel/SpinWheel.tsx b/client/src/components/wheel/SpinWheel.tsx
new file mode 100644
index 0000000..b3fed9f
--- /dev/null
+++ b/client/src/components/wheel/SpinWheel.tsx
@@ -0,0 +1,278 @@
+import {
+ useCallback,
+ useMemo,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react'
+import type { KeyboardEvent, ReactNode, Ref } from 'react'
+import {
+ layoutSegments,
+ nextRotation,
+ segmentPath,
+ SPOKE_COUNT,
+ truncate,
+} from '../../utils/wheel'
+import { ResultDialog } from './ResultDialog'
+
+/** `openDialog = false` lar siden selv bestemme hva som vises når hjulet stopper. */
+export type SpinWheelHandle = { spin: (openDialog?: boolean) => void }
+
+type SpinWheelProps = {
+ ref?: Ref
+ id: string
+ title: string
+ items: T[]
+ getId: (item: T) => string
+ getLabel: (item: T) => string
+ /** Adressen til kompass-knappen ved siden av resultatet, hvis den finnes. */
+ getLink?: (item: T) => string
+ dialogTitle: (item: T) => string
+ renderResult: (item: T) => ReactNode
+ /** For eksempel et filter, som vises ved siden av overskriften. */
+ controls?: ReactNode
+ emptyText: string
+ onSpinningChange?: (isSpinning: boolean) => void
+ /** Kalles når hjulet har stoppet på et felt. */
+ onResult?: (item: T) => void
+ hidden?: boolean
+}
+
+const SPIN_MS = 4500
+const COLOR_COUNT = 4
+/** Pinne med håndtak, tegnet rett opp fra midten av roret. */
+const SPOKE_PATH =
+ 'M-2.6 -14V-106L-3.4 -112Q-6.2 -120 -4.8 -128Q0 -135 4.8 -128Q6.2 -120 3.4 -112L2.6 -106V-14Z'
+
+const prefersReducedMotion = () =>
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+/** Fargen til et felt. Første og siste felt skal ikke ha samme farge. */
+function colorOf(index: number, count: number) {
+ const isClashing =
+ count > 1 && index === count - 1 && index % COLOR_COUNT === 0
+ return isClashing ? 2 : index % COLOR_COUNT
+}
+
+/** Lite kompass som lenker til stedets nettside. */
+function CompassIcon() {
+ return (
+
+
+
+
+ )
+}
+
+/**
+ * Et lykkehjul. Hjulet snurrer når man trykker på midten, og stopper på et
+ * tilfeldig felt. Resultatet vises i et lite vindu og lagres i et felt under
+ * hjulet, så det kan åpnes igjen.
+ */
+export function SpinWheel({
+ ref,
+ id,
+ title,
+ items,
+ getId,
+ getLabel,
+ getLink,
+ dialogTitle,
+ renderResult,
+ controls,
+ emptyText,
+ onSpinningChange,
+ onResult,
+ hidden,
+}: SpinWheelProps) {
+ const [rotation, setRotation] = useState(0)
+ const [isSpinning, setIsSpinning] = useState(false)
+ const [result, setResult] = useState(null)
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+ const timerRef = useRef(0)
+ const onResultRef = useRef(onResult)
+
+ useEffect(() => {
+ onResultRef.current = onResult
+ }, [onResult])
+
+ const count = items.length
+ const segments = useMemo(() => layoutSegments(count), [count])
+
+ useEffect(() => {
+ onSpinningChange?.(isSpinning)
+ }, [isSpinning, onSpinningChange])
+ useEffect(() => () => window.clearTimeout(timerRef.current), [])
+
+ const spin = useCallback(
+ (openDialog = true) => {
+ if (isSpinning || count === 0) return
+
+ const index = Math.floor(Math.random() * count)
+ const duration = prefersReducedMotion() ? 0 : SPIN_MS
+
+ setResult(null)
+ setIsDialogOpen(false)
+ setIsSpinning(true)
+ setRotation(nextRotation(rotation, segments[index], Math.random()))
+
+ timerRef.current = window.setTimeout(() => {
+ setIsSpinning(false)
+ setResult(items[index])
+ setIsDialogOpen(openDialog)
+ onResultRef.current?.(items[index])
+ }, duration)
+ },
+ [isSpinning, count, rotation, items, segments],
+ )
+
+ useImperativeHandle(ref, () => ({ spin }), [spin])
+
+ const handleHubKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ spin()
+ }
+ }
+
+ // Jo flere felt, desto mindre tekst, og desto kortere navn.
+ const narrowest = Math.min(...segments.map(({ start, end }) => end - start))
+ const fontSize = Math.min(7, ((narrowest * Math.PI) / 180) * 50 * 0.7)
+ const maxChars = Math.floor(58 / (0.55 * fontSize))
+ const link = result && getLink ? getLink(result) : null
+
+ const status = isSpinning
+ ? 'Hjulet snurrer …'
+ : result
+ ? `Valgt: ${getLabel(result)}`
+ : ''
+
+ return (
+
+
+
+ {count === 0 ? (
+ {emptyText}
+ ) : (
+
+
+
+ {items.map((item, index) => {
+ const segment = segments[index]
+ const { mid } = segment
+ // Tekst på venstre halvdel snus, så den ikke står på hodet.
+ const isFlipped = mid > 180
+
+ return (
+
+
+
+ {truncate(getLabel(item), maxChars)}
+
+
+ )
+ })}
+
+
+ {Array.from({ length: SPOKE_COUNT }, (_, spoke) => (
+
+
+
+ ))}
+
+
+
+
+
+ spin()}
+ onKeyDown={handleHubKeyDown}
+ >
+
+
+ SPINN
+
+
+
+ )}
+
+
+ {status}
+
+
+
+ setIsDialogOpen(true)}
+ >
+ {result ? getLabel(result) : 'Ingen valgt ennå'}
+
+ {link && result && (
+
+
+
+ )}
+
+
+ {result && (
+ setIsDialogOpen(false)}
+ >
+ {renderResult(result)}
+
+ )}
+
+ )
+}
diff --git a/client/src/data/mockChallenges.ts b/client/src/data/mockChallenges.ts
new file mode 100644
index 0000000..95f4881
--- /dev/null
+++ b/client/src/data/mockChallenges.ts
@@ -0,0 +1,72 @@
+import type { Challenge } from '../types/challenge'
+
+type ChallengeSeed = Omit
+
+/**
+ * Mockdata til utvikling. Alle utfordringene er ufarlige og kan gjøres med
+ * alkoholfri drikke. Erstattes av data fra backend senere.
+ */
+const seeds: ChallengeSeed[] = [
+ {
+ title: 'Bestill med aksent',
+ description: 'Bestill neste drikke med en utenlandsk aksent.',
+ },
+ {
+ title: 'Motsatt hånd',
+ description: 'Hold drikken i den hånden du ikke bruker, i ti minutter.',
+ },
+ {
+ title: 'Skål med en fremmed',
+ description: 'Skål vennlig med noen du ikke kjenner.',
+ },
+ {
+ title: 'Ingen ja eller nei',
+ description: 'Si verken «ja» eller «nei» de neste ti minuttene.',
+ },
+ {
+ title: 'Bytt drikk',
+ description: 'Bytt drikk med en i følget og smak på den.',
+ },
+ {
+ title: 'Noe nytt',
+ description: 'Bestill noe du aldri har smakt før.',
+ },
+ {
+ title: 'Sjømannsvise',
+ description: 'Syng første vers av en sjømannsvise sammen med følget.',
+ },
+ {
+ title: 'Bartenderens valg',
+ description: 'Spør bartenderen om dagens anbefaling og bestill den.',
+ },
+ {
+ title: 'Piratprat',
+ description: 'Snakk som en pirat i fem minutter.',
+ },
+ {
+ title: 'Gjett ingrediensene',
+ description: 'Gjett tre ingredienser i drikken din før du leser menyen.',
+ },
+ {
+ title: 'Skål på rim',
+ description: 'Lag en skål på rim og si den høyt.',
+ },
+ {
+ title: 'Den rareste på menyen',
+ description: 'Finn den mest uvanlige drikken på menyen og bestill den.',
+ },
+ {
+ title: 'Vits',
+ description: 'Fortell en vits som får minst én person til å smile.',
+ },
+ {
+ title: 'Bildet av kvelden',
+ description:
+ 'Ta et bilde av følget med stedets fineste detalj i bakgrunnen.',
+ },
+]
+
+export const mockChallenges: Challenge[] = seeds.map((seed, index) => ({
+ ...seed,
+ id: `challenge-${index + 1}`,
+}))
diff --git a/client/src/data/mockPlaces.ts b/client/src/data/mockPlaces.ts
new file mode 100644
index 0000000..e86ced5
--- /dev/null
+++ b/client/src/data/mockPlaces.ts
@@ -0,0 +1,333 @@
+import type { Place } from '../types/place'
+import { toCoordinates } from '../utils/mapProjection'
+
+type PlaceSeed = Omit<
+ Place,
+ 'id' | 'latitude' | 'longitude' | 'websiteUrl' | 'imageUrl'
+>
+
+/**
+ * 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: 'Nattklubb',
+ 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 Klubb',
+ type: 'Nattklubb',
+ 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'],
+ },
+]
+
+/** Plassering på det stiliserte kartet (x, y), i samme rekkefølge som stedene. */
+const mapPoints: [number, number][] = [
+ [250, 230],
+ [360, 300],
+ [430, 230],
+ [440, 410],
+ [590, 420],
+ [610, 330],
+ [700, 260],
+ [780, 210],
+ [830, 330],
+ [890, 260],
+ [680, 440],
+ [770, 520],
+ [860, 450],
+ [900, 560],
+ [830, 630],
+ [640, 640],
+ [560, 610],
+ [470, 560],
+ [380, 500],
+ [300, 420],
+ [190, 360],
+ [160, 520],
+ [270, 590],
+ [390, 620],
+ [920, 380],
+]
+
+/** 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, index) => ({
+ ...seed,
+ ...toCoordinates(...mapPoints[index]),
+ id: toId(seed.name),
+ websiteUrl: 'https://example.com',
+ imageUrl: null,
+}))
diff --git a/client/src/data/mockUser.ts b/client/src/data/mockUser.ts
new file mode 100644
index 0000000..b44a2bb
--- /dev/null
+++ b/client/src/data/mockUser.ts
@@ -0,0 +1,10 @@
+import type { User } from '../types/user'
+import { mockPlaces } from './mockPlaces'
+
+/** Eksempelbruker. Erstattes av den innloggede brukeren fra backend senere. */
+export const mockUser: User = {
+ id: 'ola-normann',
+ name: 'Ola Normann',
+ avatarUrl: null,
+ favouritePlaces: mockPlaces.slice(0, 7),
+}
diff --git a/client/src/hooks/useCurrentUser.ts b/client/src/hooks/useCurrentUser.ts
new file mode 100644
index 0000000..8a77f81
--- /dev/null
+++ b/client/src/hooks/useCurrentUser.ts
@@ -0,0 +1,9 @@
+import { fetchCurrentUser } from '../api/user'
+import { useFetch } from './useFetch'
+
+/** Henter brukeren som vises i toppen og på profilsiden. */
+export function useCurrentUser() {
+ const { data: user, status } = useFetch(fetchCurrentUser)
+
+ return { user, status }
+}
diff --git a/client/src/hooks/useFetch.ts b/client/src/hooks/useFetch.ts
new file mode 100644
index 0000000..cefa50d
--- /dev/null
+++ b/client/src/hooks/useFetch.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react'
+
+type Status = 'loading' | 'ready' | 'error'
+
+/** Svar vi allerede har fått, så en side vises uten ventetid når man kommer tilbake. */
+const cache = new Map()
+
+/**
+ * Henter data når komponenten vises, og husker svaret mens appen er åpen.
+ * Funksjonen må ha en fast identitet (definert utenfor komponenten), ellers
+ * hentes dataene på nytt.
+ */
+export function useFetch(fetcher: () => Promise) {
+ const [data, setData] = useState(
+ () => (cache.get(fetcher) as T | undefined) ?? null,
+ )
+ const [status, setStatus] = useState(() =>
+ cache.has(fetcher) ? 'ready' : 'loading',
+ )
+
+ useEffect(() => {
+ if (cache.has(fetcher)) return
+
+ let isCurrent = true
+
+ fetcher()
+ .then((result) => {
+ cache.set(fetcher, result)
+ if (!isCurrent) return
+ setData(result)
+ setStatus('ready')
+ })
+ .catch(() => {
+ if (isCurrent) setStatus('error')
+ })
+
+ return () => {
+ isCurrent = false
+ }
+ }, [fetcher])
+
+ return { data, status }
+}
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..9ec0639
--- /dev/null
+++ b/client/src/hooks/usePlaces.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useState } from 'react'
+import { fetchPlaces } from '../api/places'
+import type { Place, PlacesPage } 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'
+
+/** Første side huskes, så hovedsiden er klar med en gang når man kommer tilbake. */
+let firstPage: PlacesPage | null = null
+
+/** Henter utesteder og lar brukeren laste flere. */
+export function usePlaces() {
+ const [places, setPlaces] = useState(firstPage?.items ?? [])
+ const [totalCount, setTotalCount] = useState(firstPage?.totalCount ?? 0)
+ const [status, setStatus] = useState(firstPage ? 'idle' : 'loading')
+
+ useEffect(() => {
+ if (firstPage) return
+
+ let ignore = false
+
+ fetchPlaces(0, PAGE_SIZE)
+ .then((page) => {
+ firstPage = 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/hooks/useRoute.ts b/client/src/hooks/useRoute.ts
new file mode 100644
index 0000000..c57db54
--- /dev/null
+++ b/client/src/hooks/useRoute.ts
@@ -0,0 +1,32 @@
+import { useSyncExternalStore } from 'react'
+
+export type Route = 'home' | 'map' | 'user' | 'challenges'
+
+/** Adressene i nettleseren. Hash-adresser trenger ingen serverkonfigurasjon. */
+export const HOME_HREF = '#/'
+export const MAP_HREF = '#/kart'
+export const USER_HREF = '#/bruker'
+export const CHALLENGES_HREF = '#/utfordringer'
+
+function subscribe(onChange: () => void) {
+ window.addEventListener('hashchange', onChange)
+ return () => window.removeEventListener('hashchange', onChange)
+}
+
+function getRoute(): Route {
+ switch (window.location.hash) {
+ case MAP_HREF:
+ return 'map'
+ case USER_HREF:
+ return 'user'
+ case CHALLENGES_HREF:
+ return 'challenges'
+ default:
+ return 'home'
+ }
+}
+
+/** Hvilken side som vises, styrt av adressen. */
+export function useRoute(): Route {
+ return useSyncExternalStore(subscribe, getRoute)
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..7e68479
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,63 @@
+: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;
+ animation: 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/pages/ChallengesPage.css b/client/src/pages/ChallengesPage.css
new file mode 100644
index 0000000..5337cdd
--- /dev/null
+++ b/client/src/pages/ChallengesPage.css
@@ -0,0 +1,390 @@
+/* ---------- Utfordringer ---------- */
+
+.wheel-section {
+ --wood: #a56a2c;
+ --wood-dark: #4d2c0e;
+ --brass: #e0b44a;
+}
+
+.challenges-page {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 1.5rem;
+ padding: 1.5rem 1rem 2rem;
+}
+
+/* Valg av hjul: tre knapper som henger sammen. */
+.mode-toggle {
+ display: flex;
+ justify-self: center;
+ margin: 0;
+ padding: 0;
+ border: 2px solid var(--navy-700);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.mode-option span {
+ display: block;
+ padding: 0.55rem 1.1rem;
+ color: var(--navy-700);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.mode-option input:checked + span {
+ background: var(--navy-700);
+ color: var(--eggshell);
+}
+
+.mode-option input:focus-visible + span {
+ outline: 3px solid var(--blue-300);
+ outline-offset: -3px;
+}
+
+.mode-toggle:disabled span {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.wheels {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 30rem));
+ justify-content: center;
+ gap: 2rem;
+}
+
+.wheel-section {
+ display: grid;
+ align-content: start;
+ gap: 0.75rem;
+}
+
+.wheel-section[hidden] {
+ display: none;
+}
+
+.wheel-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding-bottom: 0.4rem;
+ border-bottom: 2px solid var(--navy-700);
+}
+
+.wheel-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+}
+
+.wheel-filter {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--muted);
+ font-weight: 600;
+}
+
+.wheel-filter select {
+ padding: 0.35rem 0.6rem;
+ border: 2px solid var(--navy-700);
+ border-radius: 0.5rem;
+ background: var(--surface);
+ color: var(--navy-900);
+ font: inherit;
+}
+
+.wheel {
+ width: 100%;
+ max-height: 32rem;
+ overflow: visible;
+}
+
+.wheel-empty {
+ display: grid;
+ place-items: center;
+ min-height: 12rem;
+ margin: 0;
+ color: var(--muted);
+ text-align: center;
+}
+
+.wheel-rotor {
+ transform-origin: 0 0;
+ transition-property: transform;
+ transition-timing-function: cubic-bezier(0.12, 0.6, 0.1, 1);
+}
+
+.wheel-color-0 path {
+ color: var(--navy-700);
+ fill: var(--navy-700);
+}
+.wheel-color-1 path {
+ color: var(--blue-100);
+ fill: var(--blue-100);
+}
+.wheel-color-2 path {
+ color: var(--navy-500);
+ fill: var(--navy-500);
+}
+.wheel-color-3 path {
+ color: var(--blue-300);
+ fill: var(--blue-300);
+}
+
+.wheel-color-0 text,
+.wheel-color-2 text {
+ fill: var(--eggshell);
+}
+.wheel-color-1 text,
+.wheel-color-3 text {
+ fill: var(--navy-900);
+}
+
+/* Kanten i samme farge som feltet skjuler sømmen mellom to felt. */
+.wheel-color-0 path,
+.wheel-color-1 path,
+.wheel-color-2 path,
+.wheel-color-3 path {
+ stroke: currentColor;
+ stroke-width: 0.4;
+}
+
+.wheel-section text {
+ font-weight: 600;
+ pointer-events: none;
+}
+
+.wheel-spoke {
+ fill: var(--wood);
+}
+
+/* Treverk mellom feltene, så pinnene går i ett med resten av roret. */
+.wheel-backing {
+ fill: var(--wood);
+}
+
+.wheel-rim {
+ fill: none;
+ stroke: var(--wood);
+ stroke-width: 13;
+ paint-order: stroke;
+}
+
+.wheel-pointer {
+ fill: var(--brass);
+ stroke: var(--navy-900);
+ stroke-width: 1.5;
+ stroke-linejoin: round;
+}
+
+.wheel-hub {
+ cursor: pointer;
+ outline: none;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.wheel-hub circle {
+ fill: #f1e2b3;
+ stroke: var(--wood);
+ stroke-width: 4;
+}
+
+.wheel-hub text {
+ fill: var(--navy-900);
+ font-size: 6px;
+ font-weight: 800;
+ letter-spacing: 0.05em;
+ pointer-events: none;
+}
+
+.wheel-hub:hover circle,
+.wheel-hub:focus-visible circle {
+ fill: var(--blue-100);
+}
+
+.wheel-hub:focus-visible circle {
+ stroke: var(--navy-500);
+ stroke-width: 4.5;
+}
+
+.wheel-hub[aria-disabled='true'] {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+/* Feltet under hjulet, med kompass ved siden av når det finnes. */
+.wheel-result {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.wheel-result.has-link {
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.result-field {
+ min-height: 2.75rem;
+ padding: 0.5rem 1rem;
+ border: 2px solid var(--navy-700);
+ border-radius: 0.6rem;
+ background: var(--surface);
+ color: var(--navy-900);
+ font: inherit;
+ font-weight: 700;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.result-field:hover:not(:disabled) {
+ background: var(--blue-100);
+}
+
+.result-field:disabled {
+ border-style: dashed;
+ color: var(--muted);
+ font-weight: 400;
+ cursor: default;
+}
+
+.result-field:focus-visible {
+ outline: 3px solid var(--blue-300);
+ outline-offset: 2px;
+}
+
+.result-link {
+ display: grid;
+ place-items: center;
+}
+
+.result-link svg {
+ width: 1.6rem;
+ height: 1.6rem;
+}
+
+/* Vinduet med resultatet. */
+.result-dialog {
+ width: min(22rem, calc(100vw - 2rem));
+ padding: 1rem;
+ border: 2px solid var(--navy-700);
+ border-radius: 0.9rem;
+ background: var(--surface);
+ color: var(--navy-900);
+}
+
+.result-dialog::backdrop {
+ background: rgb(10 34 64 / 0.6);
+}
+
+.result-dialog-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.result-dialog-header h2 {
+ margin: 0;
+ font-size: 1.25rem;
+ line-height: 1.2;
+}
+
+.close-button svg {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.result-body {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.result-body p {
+ margin: 0;
+}
+
+.challenge-text {
+ font-size: 1.1rem;
+}
+
+.fine-print {
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.spin-all {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.close-button,
+.result-link {
+ width: 2.75rem;
+ height: 2.75rem;
+ padding: 0;
+ border-radius: 50%;
+ color: var(--navy-900);
+}
+
+.close-button {
+ flex: none;
+ width: 2.25rem;
+ height: 2.25rem;
+}
+
+.result-dialog.wide {
+ width: min(46rem, calc(100vw - 2rem));
+}
+
+.combined-result {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
+ gap: 1.25rem;
+}
+
+.combined-result article {
+ display: grid;
+ align-content: start;
+ gap: 0.5rem;
+}
+
+.combined-result article + article {
+ padding-left: 1.25rem;
+ border-left: 2px solid var(--blue-300);
+}
+
+.combined-result h3 {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.combined-name {
+ margin: 0;
+ font-size: 1.25rem;
+ font-weight: 700;
+}
+
+@media (max-width: 40rem) {
+ .combined-result article + article {
+ padding: 1.25rem 0 0;
+ border-top: 2px solid var(--blue-300);
+ border-left: 0;
+ }
+}
+
+.close-button {
+ margin-left: auto;
+}
diff --git a/client/src/pages/ChallengesPage.tsx b/client/src/pages/ChallengesPage.tsx
new file mode 100644
index 0000000..ca760ff
--- /dev/null
+++ b/client/src/pages/ChallengesPage.tsx
@@ -0,0 +1,195 @@
+import { useMemo, useRef, useState } from 'react'
+import { fetchAllPlaces } from '../api/places'
+import { fetchChallenges } from '../api/challenges'
+import { ChallengeResult } from '../components/wheel/ChallengeResult'
+import { CombinedResult } from '../components/wheel/CombinedResult'
+import { PlaceResult } from '../components/wheel/PlaceResult'
+import { ResultDialog } from '../components/wheel/ResultDialog'
+import { SpinWheel } from '../components/wheel/SpinWheel'
+import type { SpinWheelHandle } from '../components/wheel/SpinWheel'
+import { useFetch } from '../hooks/useFetch'
+import { useNow } from '../hooks/useNow'
+import type { Challenge } from '../types/challenge'
+import type { Place } from '../types/place'
+import './ChallengesPage.css'
+
+type Mode = 'both' | 'places' | 'challenges'
+
+const MODES: { value: Mode; label: string }[] = [
+ { value: 'both', label: 'Begge' },
+ { value: 'places', label: 'Steder' },
+ { value: 'challenges', label: 'Utfordringer' },
+]
+
+const FILTERS = ['Alle', 'Nattklubb', 'Pub', 'Bar', 'Vinbar'] as const
+type Filter = (typeof FILTERS)[number]
+
+export function ChallengesPage() {
+ const now = useNow()
+ const { data: places, status: placesStatus } = useFetch(fetchAllPlaces)
+ const { data: challenges, status: challengesStatus } =
+ useFetch(fetchChallenges)
+
+ const [mode, setMode] = useState('both')
+ const [filter, setFilter] = useState('Alle')
+ const [isPlaceSpinning, setIsPlaceSpinning] = useState(false)
+ const [isChallengeSpinning, setIsChallengeSpinning] = useState(false)
+
+ // Når begge hjulene snurres samtidig, vises resultatene i ett felles vindu.
+ const [combined, setCombined] = useState<{
+ place: Place
+ challenge: Challenge
+ } | null>(null)
+ const [isCombinedOpen, setIsCombinedOpen] = useState(false)
+ const pending = useRef<{ place?: Place; challenge?: Challenge } | null>(null)
+
+ const collect = (part: { place?: Place; challenge?: Challenge }) => {
+ if (!pending.current) return
+ pending.current = { ...pending.current, ...part }
+ const { place, challenge } = pending.current
+
+ if (place && challenge) {
+ pending.current = null
+ setCombined({ place, challenge })
+ setIsCombinedOpen(true)
+ }
+ }
+
+ const placeWheel = useRef(null)
+ const challengeWheel = useRef(null)
+
+ const isSpinning = isPlaceSpinning || isChallengeSpinning
+ const showPlaces = mode !== 'challenges'
+ const showChallenges = mode !== 'places'
+
+ const filteredPlaces = useMemo(() => {
+ if (!places) return []
+ return filter === 'Alle'
+ ? places
+ : places.filter((place) => place.type === filter)
+ }, [places, filter])
+
+ const spinAll = () => {
+ const isJoint = showPlaces && showChallenges
+
+ pending.current = isJoint ? {} : null
+ setIsCombinedOpen(false)
+ if (showPlaces) placeWheel.current?.spin(!isJoint)
+ if (showChallenges) challengeWheel.current?.spin(!isJoint)
+ }
+
+ const placesEmptyText =
+ placesStatus === 'loading'
+ ? 'Laster steder …'
+ : placesStatus === 'error'
+ ? 'Kunne ikke hente steder.'
+ : 'Ingen steder passer filteret.'
+
+ const challengesEmptyText =
+ challengesStatus === 'loading'
+ ? 'Laster utfordringer …'
+ : 'Kunne ikke hente utfordringer.'
+
+ return (
+ <>
+
+
+ Vis hjul
+ {MODES.map(({ value, label }) => (
+
+ setMode(value)}
+ />
+ {label}
+
+ ))}
+
+
+
+ {/* Skjules i stedet for å fjernes, så hjulene husker resultatet. */}
+
+ key={filter}
+ ref={placeWheel}
+ id="place-wheel"
+ title="Sted"
+ items={filteredPlaces}
+ getId={(place) => place.id}
+ getLabel={(place) => place.name}
+ getLink={(place) => place.websiteUrl}
+ dialogTitle={(place) => place.name}
+ renderResult={(place) => }
+ emptyText={placesEmptyText}
+ onSpinningChange={setIsPlaceSpinning}
+ onResult={(place) => collect({ place })}
+ hidden={!showPlaces}
+ controls={
+
+ Filter
+ setFilter(event.target.value as Filter)}
+ >
+ {FILTERS.map((option) => (
+
+ {option}
+
+ ))}
+
+
+ }
+ />
+
+
+ ref={challengeWheel}
+ id="challenge-wheel"
+ title="Utfordring"
+ items={challenges ?? []}
+ getId={(challenge) => challenge.id}
+ getLabel={(challenge) => challenge.title}
+ dialogTitle={(challenge) => challenge.title}
+ renderResult={(challenge) => (
+
+ )}
+ emptyText={challengesEmptyText}
+ onSpinningChange={setIsChallengeSpinning}
+ onResult={(challenge) => collect({ challenge })}
+ hidden={!showChallenges}
+ />
+
+
+
+
+ {mode === 'both' ? 'Spin begge' : 'Spin'}
+
+
+
+
+ {combined && (
+ setIsCombinedOpen(false)}
+ >
+
+
+ )}
+ >
+ )
+}
diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx
new file mode 100644
index 0000000..b4da6ae
--- /dev/null
+++ b/client/src/pages/HomePage.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react'
+import { FeaturedPlace } from '../components/FeaturedPlace'
+import { PlaceList } from '../components/PlaceList'
+import { useNow } from '../hooks/useNow'
+import { PAGE_SIZE, usePlaces } from '../hooks/usePlaces'
+import type { Place } from '../types/place'
+
+export function HomePage() {
+ 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)}
+ />
+
+ >
+ )}
+
+ )
+}
diff --git a/client/src/pages/MapPage.css b/client/src/pages/MapPage.css
new file mode 100644
index 0000000..e02540a
--- /dev/null
+++ b/client/src/pages/MapPage.css
@@ -0,0 +1,428 @@
+/* ---------- Kartside ---------- */
+
+.map-page {
+ --gold: #c9972b;
+ --sheet-height: 42dvh;
+
+ position: relative;
+ height: max(32rem, 100dvh);
+ overflow: hidden;
+ background: var(--blue-100);
+}
+
+/* ---------- Kartflaten ---------- */
+
+.map-viewport {
+ position: absolute;
+ inset: 0;
+}
+
+.map-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+}
+
+.map-canvas:active {
+ cursor: grabbing;
+}
+
+.map-canvas:focus-visible {
+ outline-offset: -6px;
+}
+
+.map-land {
+ fill: #efe6cc;
+}
+
+.map-grid {
+ fill: none;
+ stroke: var(--navy-500);
+ stroke-dasharray: 4 8;
+ opacity: 0.15;
+}
+
+.map-park {
+ fill: #d9e5c8;
+}
+
+.map-water {
+ fill: var(--blue-100);
+ stroke: var(--navy-500);
+ stroke-width: 2;
+}
+
+.map-depth {
+ fill: none;
+ stroke: var(--blue-300);
+ stroke-width: 1.5;
+ stroke-dasharray: 2 7;
+ stroke-linecap: round;
+}
+
+.map-wave {
+ fill: none;
+ stroke: var(--blue-300);
+ stroke-width: 1.5;
+ stroke-linecap: round;
+}
+
+.map-river-edge,
+.map-river,
+.map-road-edge,
+.map-road {
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.map-river-edge {
+ stroke: var(--navy-500);
+ stroke-width: 26;
+}
+
+.map-river {
+ stroke: var(--blue-100);
+ stroke-width: 22;
+}
+
+.map-road-edge {
+ stroke: #d8ccab;
+ stroke-width: 9;
+}
+
+.map-road {
+ stroke: var(--surface);
+ stroke-width: 6;
+}
+
+.map-building {
+ fill: #ddd1b0;
+ stroke: #bfb28c;
+}
+
+.map-label {
+ fill: var(--navy-500);
+ font-size: 20px;
+ font-style: italic;
+ letter-spacing: 0.25em;
+ opacity: 0.8;
+ pointer-events: none;
+}
+
+.map-label-small {
+ font-size: 15px;
+ letter-spacing: 0.1em;
+}
+
+/* ---------- Merker ---------- */
+
+.map-pin {
+ cursor: pointer;
+ outline: none;
+}
+
+.map-pin-body {
+ transition: transform 0.15s;
+}
+
+.map-pin-halo {
+ fill: var(--blue-300);
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.map-pin-shape {
+ fill: var(--navy-700);
+ stroke: var(--surface);
+ stroke-width: 2.5;
+ transition: fill 0.15s;
+}
+
+.map-pin-mark {
+ fill: none;
+ stroke: var(--surface);
+ stroke-width: 3;
+ stroke-linecap: round;
+}
+
+.map-pin-label {
+ fill: var(--navy-900);
+ font-size: 14px;
+ font-weight: 700;
+ opacity: 0;
+ paint-order: stroke;
+ pointer-events: none;
+ stroke: var(--surface);
+ stroke-linejoin: round;
+ stroke-width: 4px;
+ transition: opacity 0.15s;
+}
+
+.map-pin:hover .map-pin-body,
+.map-pin:focus-visible .map-pin-body,
+.map-pin-hover .map-pin-body {
+ transform: scale(1.25);
+}
+
+.map-pin:hover .map-pin-shape,
+.map-pin:focus-visible .map-pin-shape,
+.map-pin-hover .map-pin-shape {
+ fill: var(--navy-500);
+}
+
+.map-pin:hover .map-pin-halo,
+.map-pin:focus-visible .map-pin-halo,
+.map-pin-hover .map-pin-halo,
+.map-pin-selected .map-pin-halo {
+ opacity: 0.85;
+}
+
+.map-pin:hover .map-pin-label,
+.map-pin:focus-visible .map-pin-label,
+.map-pin-hover .map-pin-label,
+.map-pin-selected .map-pin-label {
+ opacity: 1;
+}
+
+.map-pin-selected .map-pin-body {
+ transform: scale(1.4);
+}
+
+.map-pin-selected .map-pin-shape {
+ fill: var(--gold);
+ stroke: var(--navy-900);
+}
+
+.map-pin-selected .map-pin-mark {
+ stroke: var(--navy-900);
+}
+
+/* ---------- Kompass og zoom ---------- */
+
+.map-compass {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ width: 3.5rem;
+ pointer-events: none;
+}
+
+.compass-ring {
+ fill: var(--surface);
+ fill-opacity: 0.85;
+ stroke: var(--navy-900);
+ stroke-width: 1.5;
+}
+
+.compass-ns {
+ fill: var(--navy-900);
+}
+
+.compass-ew {
+ fill: var(--blue-300);
+}
+
+.map-compass text {
+ fill: var(--navy-900);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.zoom-controls {
+ position: absolute;
+ right: 0.75rem;
+ bottom: calc(var(--sheet-height) + 1rem);
+ display: grid;
+ gap: 0.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.zoom-controls .map-control {
+ width: 2.75rem;
+ height: 2.75rem;
+ padding: 0;
+ border-radius: 50%;
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+/* ---------- Ruten til venstre ---------- */
+
+.map-panel {
+ position: absolute;
+ right: 0.5rem;
+ bottom: 0.5rem;
+ left: 0.5rem;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ height: var(--sheet-height);
+ overflow: hidden;
+ border: 2px solid var(--navy-900);
+ border-radius: 1rem;
+ background: var(--surface);
+ box-shadow: 0 0.75rem 2rem rgb(10 34 64 / 0.3);
+}
+
+.map-search {
+ flex: none;
+ padding: 0.75rem;
+}
+
+.map-search input {
+ width: 100%;
+ padding: 0.6rem 1rem;
+ border: 2px solid var(--navy-900);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+ transition: box-shadow 0.15s;
+}
+
+.map-search input:hover {
+ box-shadow: 0 0 0 3px var(--blue-300);
+}
+
+.map-results {
+ flex: 1;
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ border-top: 2px solid var(--navy-900);
+ list-style: none;
+}
+
+.map-result {
+ display: grid;
+ width: 100%;
+ padding: 0.65rem 1rem 0.65rem 0.75rem;
+ border: 0;
+ border-bottom: 1px solid var(--blue-100);
+ border-left: 0.3rem solid transparent;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+}
+
+.map-result:hover,
+.map-result:focus-visible {
+ border-left-color: var(--navy-700);
+ background: var(--blue-100);
+}
+
+.map-result-name {
+ font-weight: 700;
+}
+
+.map-result-meta {
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+
+.map-empty {
+ margin: 0;
+ padding: 1rem;
+ color: var(--muted);
+}
+
+.map-error {
+ position: absolute;
+ top: 0.75rem;
+ left: 50%;
+ z-index: 3;
+ margin: 0;
+ padding: 0.5rem 1rem;
+ border-radius: 0.6rem;
+ background: var(--surface);
+ color: var(--closed);
+ font-weight: 600;
+ transform: translateX(-50%);
+}
+
+/* ---------- Detaljer i ruten ---------- */
+
+.map-details {
+ display: grid;
+ align-content: start;
+ gap: 0.6rem;
+ padding: 0.75rem;
+ overflow-y: auto;
+}
+
+.map-details p {
+ margin: 0;
+}
+
+.map-details-header {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid var(--blue-100);
+}
+
+.map-details-header h2 {
+ margin: 0 auto 0 0;
+ font-size: 1.3rem;
+ line-height: 1.2;
+}
+
+.map-details-header h2:focus {
+ outline: none;
+}
+
+.back-button {
+ flex: none;
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ border-radius: 50%;
+}
+
+.back-button svg {
+ width: 1.3rem;
+ height: 1.3rem;
+}
+
+.map-details .rating {
+ flex: none;
+ padding: 0.25rem 0.7rem;
+ border-radius: 999px;
+ background: var(--blue-100);
+ font-weight: 700;
+}
+
+.map-details .rating small {
+ color: var(--muted);
+ font-weight: 400;
+}
+
+@media (min-width: 56rem) {
+ .map-page {
+ --sheet-height: 0px;
+ }
+
+ .map-panel {
+ top: 0.75rem;
+ right: auto;
+ width: 23rem;
+ height: auto;
+ }
+
+ .zoom-controls {
+ bottom: 1rem;
+ }
+}
diff --git a/client/src/pages/MapPage.tsx b/client/src/pages/MapPage.tsx
new file mode 100644
index 0000000..6366a14
--- /dev/null
+++ b/client/src/pages/MapPage.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { fetchAllPlaces } from '../api/places'
+import { MapPanel } from '../components/map/MapPanel'
+import { MapViewport } from '../components/map/MapViewport'
+import type { Inset } from '../components/map/MapViewport'
+import { useFetch } from '../hooks/useFetch'
+import { useNow } from '../hooks/useNow'
+import type { Place } from '../types/place'
+import './MapPage.css'
+
+const matches = (place: Place, query: string) =>
+ [place.name, place.type, place.area, ...place.tags].some((text) =>
+ text.toLocaleLowerCase('nb-NO').includes(query),
+ )
+
+export function MapPage() {
+ const now = useNow()
+ // Kartet viser alle steder. Når backenden er klar, byttes dette ut med en
+ // egen kartspørring (for eksempel steder innenfor utsnittet).
+ const { data, status } = useFetch(fetchAllPlaces)
+ const places = useMemo(() => data ?? [], [data])
+ const [query, setQuery] = useState('')
+ const [selectedId, setSelectedId] = useState(null)
+ const [hoveredId, setHoveredId] = useState(null)
+ const [inset, setInset] = useState({ left: 0, bottom: 0 })
+
+ const panelRef = useRef(null)
+ const lastSelectedId = useRef(null)
+
+ // Måler ruten, så kartet vet hvor mye av det som er dekket.
+ useEffect(() => {
+ const panel = panelRef.current
+ if (!panel) return
+
+ const update = () => {
+ const rect = panel.getBoundingClientRect()
+ const isDesktop = window.matchMedia('(min-width: 56rem)').matches
+ const next = isDesktop
+ ? { left: rect.right, bottom: 0 }
+ : { left: 0, bottom: window.innerHeight - rect.top }
+
+ setInset((previous) =>
+ previous.left === next.left && previous.bottom === next.bottom
+ ? previous
+ : next,
+ )
+ }
+ update()
+
+ const observer = new ResizeObserver(update)
+ observer.observe(panel)
+ window.addEventListener('resize', update)
+ return () => {
+ observer.disconnect()
+ window.removeEventListener('resize', update)
+ }
+ }, [])
+
+ // Setter fokus tilbake på stedet i listen når man går tilbake fra detaljene.
+ useEffect(() => {
+ if (selectedId === null && lastSelectedId.current) {
+ document.getElementById(`map-result-${lastSelectedId.current}`)?.focus()
+ }
+ lastSelectedId.current = selectedId
+ }, [selectedId])
+
+ const results = useMemo(() => {
+ const needle = query.trim().toLocaleLowerCase('nb-NO')
+ return needle ? places.filter((place) => matches(place, needle)) : places
+ }, [places, query])
+
+ const selected = places.find(({ id }) => id === selectedId) ?? null
+
+ // Kartet viser de samme stedene som søkelisten. Det valgte stedet vises
+ // alltid, selv om søket endres etter at det ble valgt.
+ const pins = useMemo(
+ () =>
+ selected && !results.includes(selected)
+ ? [...results, selected]
+ : results,
+ [results, selected],
+ )
+
+ return (
+
+ setSelectedId(null)}
+ onHover={setHoveredId}
+ />
+
+ {status === 'error' && (
+
+ Kunne ikke hente steder. Prøv å laste siden på nytt.
+
+ )}
+
+
+
+ )
+}
diff --git a/client/src/pages/UserPage.css b/client/src/pages/UserPage.css
new file mode 100644
index 0000000..ce0c7eb
--- /dev/null
+++ b/client/src/pages/UserPage.css
@@ -0,0 +1,176 @@
+/* ---------- Profilside ---------- */
+
+.avatar {
+ flex: none;
+ border: 3px solid var(--blue-300);
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.avatar-bg {
+ fill: var(--blue-100);
+}
+
+.avatar-figure {
+ fill: var(--navy-700);
+}
+
+.user-page {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 1.5rem;
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 1.5rem 1rem 3rem;
+}
+
+.profile-section {
+ min-width: 0;
+ padding: 1rem 1.25rem 1.25rem;
+ 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);
+}
+
+.profile-section-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid var(--blue-100);
+}
+
+.profile-section-header h2 {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0 auto 0 0;
+ font-size: 1.35rem;
+}
+
+.profile-section-header h2 svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ color: var(--navy-500);
+}
+
+.profile-empty {
+ margin: 0;
+ padding: 1rem;
+ border: 2px dashed var(--blue-300);
+ border-radius: 0.75rem;
+ color: var(--muted);
+ text-align: center;
+}
+
+/* ---------- Rad med kort ---------- */
+
+.scroll-controls {
+ display: flex;
+ gap: 0.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.scroll-button {
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ border-radius: 50%;
+}
+
+.scroll-button svg {
+ width: 1.3rem;
+ height: 1.3rem;
+}
+
+.scroll-list {
+ /* Holder skjult tekst (.visually-hidden) inne i raden, så siden ikke vokser. */
+ position: relative;
+ display: grid;
+ grid-auto-columns: 85%;
+ grid-auto-flow: column;
+ gap: 1rem;
+ margin: 0;
+ padding: 0.25rem 0.25rem 0.75rem;
+ overflow-x: auto;
+ list-style: none;
+ scroll-padding-inline: 0.25rem;
+ scroll-snap-type: x mandatory;
+ scrollbar-color: var(--blue-300) transparent;
+}
+
+.scroll-list > li {
+ display: grid;
+ scroll-snap-align: start;
+}
+
+@media (min-width: 40rem) {
+ .scroll-list {
+ grid-auto-columns: calc((100% - 2rem) / 3);
+ }
+}
+
+@media (min-width: 72rem) {
+ .scroll-list {
+ grid-auto-columns: calc((100% - 3rem) / 4);
+ }
+}
+
+.place-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 5.5rem;
+ gap: 0.15rem 0.75rem;
+ align-content: center;
+ padding: 0.75rem;
+ border: 2px solid var(--blue-100);
+ border-radius: 0.9rem;
+ background: var(--surface);
+}
+
+.place-card h3 {
+ margin: 0;
+ font-size: 1.05rem;
+ line-height: 1.25;
+}
+
+.place-card-meta {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+
+.place-card-figure {
+ grid-column: 2;
+ grid-row: 1 / span 3;
+ align-self: center;
+ margin: 0;
+ overflow: hidden;
+ border-radius: 0.5rem;
+ aspect-ratio: 4 / 3;
+}
+
+/* ---------- Badges ---------- */
+
+.badge-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin: 0 0 0.75rem;
+ padding: 0;
+ list-style: none;
+}
+
+.badge-slot {
+ display: block;
+ width: 4rem;
+ height: 4rem;
+ fill: var(--blue-100);
+ stroke: var(--blue-300);
+ stroke-dasharray: 6 5;
+ stroke-linejoin: round;
+ stroke-width: 3;
+}
diff --git a/client/src/pages/UserPage.tsx b/client/src/pages/UserPage.tsx
new file mode 100644
index 0000000..0064580
--- /dev/null
+++ b/client/src/pages/UserPage.tsx
@@ -0,0 +1,90 @@
+import type { ReactNode } from 'react'
+import { BadgeSlots } from '../components/user/BadgeSlots'
+import { CarouselSection } from '../components/user/CarouselSection'
+import { FavouritePlaceCard } from '../components/user/FavouritePlaceCard'
+import { ProfileSection } from '../components/user/ProfileSection'
+import { useCurrentUser } from '../hooks/useCurrentUser'
+import './UserPage.css'
+
+/** Små strek-ikoner som står foran overskriftene. */
+function SectionIcon({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+const placesIcon = (
+
+
+
+
+)
+
+const routesIcon = (
+
+
+
+
+
+)
+
+const badgesIcon = (
+
+
+
+
+)
+
+export function UserPage() {
+ const { user, status } = useCurrentUser()
+
+ return (
+
+ {status === 'loading' && Laster profil …
}
+ {status === 'error' && (
+
+ Kunne ikke hente profilen. Prøv å laste siden på nytt.
+
+ )}
+
+ {user && (
+ <>
+
+ {user.favouritePlaces.map((place) => (
+
+ ))}
+
+
+
+ {[]}
+
+
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/client/src/types/challenge.ts b/client/src/types/challenge.ts
new file mode 100644
index 0000000..b1c5630
--- /dev/null
+++ b/client/src/types/challenge.ts
@@ -0,0 +1,6 @@
+export type Challenge = {
+ id: string
+ title: string
+ /** Kort forklaring av hva man skal gjøre. */
+ description: string
+}
diff --git a/client/src/types/place.ts b/client/src/types/place.ts
new file mode 100644
index 0000000..2fa709c
--- /dev/null
+++ b/client/src/types/place.ts
@@ -0,0 +1,27 @@
+export type VenueType =
+ 'Bar' | 'Pub' | 'Bryggeri' | 'Vinbar' | 'Cocktailbar' | 'Nattklubb'
+
+export type Place = {
+ id: string
+ name: string
+ type: VenueType
+ area: string
+ address: string
+ latitude: number
+ longitude: number
+ /** 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/types/user.ts b/client/src/types/user.ts
new file mode 100644
index 0000000..6c6ba42
--- /dev/null
+++ b/client/src/types/user.ts
@@ -0,0 +1,9 @@
+import type { Place } from './place'
+
+export type User = {
+ id: string
+ name: string
+ /** Null når brukeren mangler profilbilde; da vises en plassholder. */
+ avatarUrl: string | null
+ favouritePlaces: Place[]
+}
diff --git a/client/src/utils/mapProjection.ts b/client/src/utils/mapProjection.ts
new file mode 100644
index 0000000..c86b6eb
--- /dev/null
+++ b/client/src/utils/mapProjection.ts
@@ -0,0 +1,27 @@
+/** Størrelsen på det stiliserte kartet, i kartets egne enheter. */
+export const MAP_WIDTH = 1000
+export const MAP_HEIGHT = 800
+
+// Utsnittet av Trondheim som kartet dekker (omtrent 5 × 5 km).
+const WEST = 10.34
+const LONGITUDE_SPAN = 0.12
+const NORTH = 63.455
+const LATITUDE_SPAN = 0.048
+
+export type MapPoint = { x: number; y: number }
+
+/** Gjør breddegrad og lengdegrad om til et punkt på kartet. */
+export function toMapPoint(latitude: number, longitude: number): MapPoint {
+ return {
+ x: ((longitude - WEST) / LONGITUDE_SPAN) * MAP_WIDTH,
+ y: ((NORTH - latitude) / LATITUDE_SPAN) * MAP_HEIGHT,
+ }
+}
+
+/** Det omvendte: et punkt på kartet blir til koordinater. */
+export function toCoordinates(x: number, y: number) {
+ return {
+ latitude: Number((NORTH - (y / MAP_HEIGHT) * LATITUDE_SPAN).toFixed(5)),
+ longitude: Number((WEST + (x / MAP_WIDTH) * LONGITUDE_SPAN).toFixed(5)),
+ }
+}
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/src/utils/wheel.ts b/client/src/utils/wheel.ts
new file mode 100644
index 0000000..007b3ce
--- /dev/null
+++ b/client/src/utils/wheel.ts
@@ -0,0 +1,80 @@
+/** Hjulets ytre radius i tegningens egne enheter. */
+export const WHEEL_RADIUS = 100
+/** Feltene starter utenfor navet. */
+export const INNER_RADIUS = 24
+/** Antall pinner på roret. Feltene deles likt mellom dem. */
+export const SPOKE_COUNT = 8
+/** Hvor mange grader på hver side av en pinne som holdes fri for felt. */
+const SPOKE_CLEARANCE = 4
+
+export type Segment = { start: number; end: number; mid: number }
+
+const round = (value: number) => Number(value.toFixed(3))
+
+/** Punkt på en sirkel, målt i grader fra toppen og med klokka. */
+function pointAt(angle: number, radius: number) {
+ const radians = (angle * Math.PI) / 180
+ return {
+ x: round(radius * Math.sin(radians)),
+ y: round(-radius * Math.cos(radians)),
+ }
+}
+
+/**
+ * Plasserer feltene mellom pinnene. Pinne `k` står på `k * 45` grader, og
+ * feltene fordeles jevnt over rommene mellom dem uten å krysse en pinne.
+ */
+export function layoutSegments(count: number): Segment[] {
+ const sector = 360 / SPOKE_COUNT
+ const segments: Segment[] = []
+
+ for (let room = 0; room < SPOKE_COUNT; room++) {
+ const first = Math.floor((count * room) / SPOKE_COUNT)
+ const last = Math.floor((count * (room + 1)) / SPOKE_COUNT)
+ const inRoom = last - first
+ const from = room * sector + SPOKE_CLEARANCE
+ const step = (sector - 2 * SPOKE_CLEARANCE) / Math.max(inRoom, 1)
+
+ for (let i = 0; i < inRoom; i++) {
+ const start = from + i * step
+ segments.push({ start, end: start + step, mid: start + step / 2 })
+ }
+ }
+
+ return segments
+}
+
+/** Tegner ett felt som en ringbit mellom navet og kanten. */
+export function segmentPath({ start, end }: Segment): string {
+ const outerStart = pointAt(start, WHEEL_RADIUS)
+ const outerEnd = pointAt(end, WHEEL_RADIUS)
+ const innerStart = pointAt(start, INNER_RADIUS)
+ const innerEnd = pointAt(end, INNER_RADIUS)
+ const largeArc = end - start > 180 ? 1 : 0
+
+ return `M${innerStart.x} ${innerStart.y}L${outerStart.x} ${outerStart.y}A${WHEEL_RADIUS} ${WHEEL_RADIUS} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}L${innerEnd.x} ${innerEnd.y}A${INNER_RADIUS} ${INNER_RADIUS} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}Z`
+}
+
+/**
+ * Hvor mye hjulet skal være rotert for at feltet stopper under pekeren
+ * øverst. `jitter` (0–1) flytter stoppen litt innenfor feltet, så pekeren
+ * aldri havner på en pinne. Hjulet snurrer `extraTurns` hele runder først.
+ */
+export function nextRotation(
+ current: number,
+ { start, end }: Segment,
+ jitter: number,
+ extraTurns = 5,
+): number {
+ const landing = (start + end) / 2 + (jitter - 0.5) * (end - start) * 0.7
+ const delta = ((-(current + landing) % 360) + 360) % 360
+
+ return current + extraTurns * 360 + delta
+}
+
+/** Kutter en tekst så den passer i feltet. */
+export function truncate(text: string, maxLength: number): string {
+ return text.length <= maxLength
+ ? text
+ : `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`
+}
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()],
+})