From 7eef2a8fa6b622f7500ffa20de031b8f448ec5aa Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 16 Oct 2026 08:23:51 +0200 Subject: [PATCH 01/14] chore: sett opp Prettier og la lint hoppe over node_modules.nosync --- .prettierignore | 5 +++++ .prettierrc | 5 +++++ eslint.config.js | 5 ++++- package-lock.json | 34 ++++++++++++++++++++++++++++++++++ package.json | 6 +++++- 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2c75786 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +**/node_modules.nosync +package-lock.json +server/data.nosync diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..75a894a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/eslint.config.js b/eslint.config.js index ef614d2..a471c5e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,10 +3,12 @@ import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' +import prettier from 'eslint-config-prettier' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + // *.nosync er node_modules flyttet ut av iCloud-synkronisering på Mac + globalIgnores(['dist', '**/node_modules.nosync']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -14,6 +16,7 @@ export default defineConfig([ tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, + prettier, ], languageOptions: { globals: globals.browser, diff --git a/package-lock.json b/package-lock.json index c225cea..4db0774 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,9 +27,11 @@ "@vitejs/plugin-react": "^6.1.1", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "prettier": "^3.9.10", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.3.0" @@ -1987,6 +1989,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", @@ -3147,6 +3165,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", diff --git a/package.json b/package.json index 03e363a..76008d7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "eslint .", "preview": "vite preview", "dev:server": "node --env-file-if-exists=.env --watch server/index.ts", - "start:server": "node --env-file-if-exists=.env server/index.ts" + "start:server": "node --env-file-if-exists=.env server/index.ts", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "graphql": "^17.0.2", @@ -31,9 +33,11 @@ "@vitejs/plugin-react": "^6.1.1", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "prettier": "^3.9.10", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.3.0" From 89a8dd153270803a757e610451c3fcd91203b9fb Mon Sep 17 00:00:00 2001 From: karstenmidthaug Date: Fri, 16 Oct 2026 08:23:51 +0200 Subject: [PATCH 02/14] style: formater all kode med Prettier --- server/README.md | 124 +-- server/authLimits.ts | 26 +- server/customRoutes.ts | 367 ++++++--- server/db.ts | 15 +- server/elevation.ts | 201 +++-- server/index.ts | 1183 +++++++++++++++++--------- server/mountainAreas.ts | 21 +- server/passwordMail.ts | 20 +- server/pointsOfInterest.ts | 14 +- server/requestSafety.ts | 123 ++- server/routeStops.ts | 150 +++- server/server.ts | 13 +- src/App.css | 81 +- src/App.tsx | 214 ++++- src/ElevationProfile.css | 76 +- src/ElevationProfile.tsx | 264 ++++-- src/Explore/Explore.css | 187 ++++- src/Explore/Explore.tsx | 180 +++- src/Explore/ExploreFilters.css | 222 ++++- src/Explore/ExploreFilters.tsx | 338 ++++++-- src/Explore/ExploreMap.css | 77 +- src/Explore/ExploreMap.tsx | 103 ++- src/Explore/ExploreSearchPanel.css | 333 ++++++-- src/Explore/ExploreSearchPanel.tsx | 391 +++++++-- src/Explore/RouteDetails.tsx | 104 ++- src/Explore/routeTypes.ts | 50 +- src/Fjellomrade.css | 719 +++++++++++++--- src/Fjellomrade.tsx | 419 ++++++++-- src/Home.css | 467 +++++++---- src/Home.tsx | 514 ++++++++---- src/Profile.css | 378 +++++++-- src/Profile.tsx | 1235 +++++++++++++++++++++++----- src/RouteBuilder.css | 215 ++++- src/RouteBuilder.tsx | 809 ++++++++++++++---- src/SiteNav.css | 157 +++- src/SiteNav.tsx | 180 +++- src/StopPlanner.tsx | 312 +++++-- src/gpx.ts | 71 +- src/graphql.ts | 12 +- src/index.css | 61 +- src/placeCategories.ts | 20 +- tsconfig.json | 5 +- utforsk-norge-prosjektplan.md | 14 +- vite.config.ts | 5 +- 44 files changed, 8192 insertions(+), 2278 deletions(-) diff --git a/server/README.md b/server/README.md index c26ba58..0f14aac 100644 --- a/server/README.md +++ b/server/README.md @@ -4,14 +4,14 @@ Backenden er én Node.js-server med ett GraphQL-endepunkt, `/graphql`. Den hente ## Oppsett -| Fil | Hva den gjør | -|---|---| -| `index.mjs` | Starter serveren (GraphQL Yoga på `127.0.0.1:4000`). Inneholder rutesøket, innloggingen, profilene og favorittene, og kobler inn fjelldelen fra `server.ts`. | -| `server.ts` | GraphQL-typene og -spørringene for fjellområdesiden. Starter ingen server selv, men legges til i skjemaet i `index.mjs`. | -| `mountainAreas.ts` | Henter fjellområdene med nøkkeltall: antall hytter, buer, gapahuker og topper, og høyeste topp. | -| `pointsOfInterest.ts` | Henter turpunktene i ett fjellområde, og søker etter turpunkter på navn. | -| `db.ts` | Én felles tilkobling (`pg.Pool`) til databasen, som brukes av alle filene over. | -| `sql/silver/*.sql` | SQL-filene som lager de rensede dataene fjelldelen henter fra. Kjøres for hånd i DBeaver. | +| Fil | Hva den gjør | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `index.mjs` | Starter serveren (GraphQL Yoga på `127.0.0.1:4000`). Inneholder rutesøket, innloggingen, profilene og favorittene, og kobler inn fjelldelen fra `server.ts`. | +| `server.ts` | GraphQL-typene og -spørringene for fjellområdesiden. Starter ingen server selv, men legges til i skjemaet i `index.mjs`. | +| `mountainAreas.ts` | Henter fjellområdene med nøkkeltall: antall hytter, buer, gapahuker og topper, og høyeste topp. | +| `pointsOfInterest.ts` | Henter turpunktene i ett fjellområde, og søker etter turpunkter på navn. | +| `db.ts` | Én felles tilkobling (`pg.Pool`) til databasen, som brukes av alle filene over. | +| `sql/silver/*.sql` | SQL-filene som lager de rensede dataene fjelldelen henter fra. Kjøres for hånd i DBeaver. | `index.mjs` er JavaScript, mens fjelldelen er skrevet i TypeScript. Node 24 kjører `.ts`-filene direkte, siden den fjerner typene ved oppstart, så backenden trenger ikke bygges. @@ -41,28 +41,44 @@ Uten `DATABASE_URL` prøver `db.ts` en lokal database via socket i `/tmp`. Tilko Databasen er delt i skjemaer etter det som kalles medallion-mønsteret. Originaldataene endres aldri. -| Skjema | Innhold | -|---|---| -| `osm_import`, `turogfriluftsruter_…` (bronze) | Rådata slik de ble importert: OpenStreetMap-uttrekket og Kartverkets Tur- og friluftsruter. | -| `silver` | Rensede data som fjelldelen henter fra: `osm_points_of_interest` og `mountain_areas`. | -| `norwegian_explorer_app` | Appens egne tabeller: brukere, sesjoner, favoritter og rutekatalogen. `index.mjs` lager tabellene for brukere, sesjoner og favoritter ved oppstart. | +| Skjema | Innhold | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `osm_import`, `turogfriluftsruter_…` (bronze) | Rådata slik de ble importert: OpenStreetMap-uttrekket og Kartverkets Tur- og friluftsruter. | +| `silver` | Rensede data som fjelldelen henter fra: `osm_points_of_interest` og `mountain_areas`. | +| `norwegian_explorer_app` | Appens egne tabeller: brukere, sesjoner, favoritter og rutekatalogen. `index.mjs` lager tabellene for brukere, sesjoner og favoritter ved oppstart. | Hvis databasebrukeren ikke har lov til å lage tabellene i `norwegian_explorer_app`, feiler bare innlogging og rutesøk. Resten av serveren, inkludert fjelldelen, virker som før. ## GraphQL-spørringer for fjelldelen -| Spørring | Svar | -|---|---| -| `mountainAreas` | Alle fjellområdene med navn, beskrivelse, grense (GeoJSON), `bbox` (rektangelet rundt området) og antall hytter, buer, gapahuker og topper, pluss høyeste topp. | -| `areaPointsOfInterest(areaId)` | Hyttene, buene, gapahukene og toppene i ett område, og dem som ligger opptil 10 km utenfor. Feltet `inside` sier om punktet er innenfor grensen. | -| `searchPointsOfInterest(query)` | Opptil 12 hytter, buer, gapahuker og topper i fjellområdene med navn som inneholder søketeksten, sammen med området hvert treff ligger i. | +| Spørring | Svar | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mountainAreas` | Alle fjellområdene med navn, beskrivelse, grense (GeoJSON), `bbox` (rektangelet rundt området) og antall hytter, buer, gapahuker og topper, pluss høyeste topp. | +| `areaPointsOfInterest(areaId)` | Hyttene, buene, gapahukene og toppene i ett område, og dem som ligger opptil 10 km utenfor. Feltet `inside` sier om punktet er innenfor grensen. | +| `searchPointsOfInterest(query)` | Opptil 12 hytter, buer, gapahuker og topper i fjellområdene med navn som inneholder søketeksten, sammen med området hvert treff ligger i. | Eksempel: ```graphql { - mountainAreas { id name hutCount peakCount highestPeak { name elevation } } - searchPointsOfInterest(query: "gjende") { areaId point { name kind capacity } } + mountainAreas { + id + name + hutCount + peakCount + highestPeak { + name + elevation + } + } + searchPointsOfInterest(query: "gjende") { + areaId + point { + name + kind + capacity + } + } } ``` @@ -74,12 +90,12 @@ Lages av `sql/silver/osm_points_of_interest.sql` fra OSM-dataene i `osm_import.o OSM-taggene oversettes til egne typer. Fjelldelen bruker de fire første: -| Type | OSM-tagg | Betydning | -|---|---|---| -| `HUT` | `tourism=alpine_hut` | Turisthytte | -| `CABIN` | `tourism=wilderness_hut`, eller `tourism=chalet` med `dnt:classification` | Bu eller koie | -| `SHELTER` | `amenity=shelter` | Gapahuk eller rasteplass | -| `PEAK` | `natural=peak` | Topp | +| Type | OSM-tagg | Betydning | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `HUT` | `tourism=alpine_hut` | Turisthytte | +| `CABIN` | `tourism=wilderness_hut`, eller `tourism=chalet` med `dnt:classification` | Bu eller koie | +| `SHELTER` | `amenity=shelter` | Gapahuk eller rasteplass | +| `PEAK` | `natural=peak` | Topp | | `CAMPSITE`, `VIEWPOINT`, `PARKING`, `TOILETS`, `WATER` | `tourism=camp_site`, `tourism=viewpoint`, `amenity=parking` med `hiking=yes`, `amenity=toilets`, `amenity=drinking_water` | Brukes ikke på fjellområdesiden i dag | Private steder, busskur, vanlig parkering og utleiehytter er filtrert bort. Høyde (`ele`) og sengeplasser (`capacity`) er tekst i OSM og gjøres om til tall. Tall som er åpenbart feil, som høyder over Galdhøpiggens 2469 moh., blir `NULL`. @@ -94,34 +110,34 @@ REFRESH MATERIALIZED VIEW silver.osm_points_of_interest; Tabellen lages av `sql/silver/mountain_areas.sql`, og fila er kilden til dataene. Vil du endre noe, endrer du fila og kjører hele fila på nytt i DBeaver med Option+X. Ikke rediger tabellen direkte. -| Kolonne | Innhold | -|---|---| -| `id` | Brukes i adressen, for eksempel `jotunheimen` i `fjellomrade.html#/jotunheimen` | -| `name`, `description` | Navn og kort beskrivelse | -| `ssr_id` | Kartverkets stedsnummer i stedsnavnregisteret (SSR), eller `NULL` hvis Kartverket ikke har området | -| `area` | Grensen, som `geometry(MultiPolygon, 4326)`. Det er en MultiPolygon fordi noen grenser består av flere deler. | +| Kolonne | Innhold | +| --------------------- | ------------------------------------------------------------------------------------------------------------- | +| `id` | Brukes i adressen, for eksempel `jotunheimen` i `fjellomrade.html#/jotunheimen` | +| `name`, `description` | Navn og kort beskrivelse | +| `ssr_id` | Kartverkets stedsnummer i stedsnavnregisteret (SSR), eller `NULL` hvis Kartverket ikke har området | +| `area` | Grensen, som `geometry(MultiPolygon, 4326)`. Det er en MultiPolygon fordi noen grenser består av flere deler. | ### Hvor grensene kommer fra Det finnes ingen offisiell avgrensning av landskapsområder som «Jotunheimen». Hvert område har derfor fått grensen fra den kilden som passer best: -| Område | Kilde | Grense | Areal* | -|---|---|---|---| -| Jotunheimen | OpenStreetMap | `natural=mountain_range`, relasjon 8512564 | 1 830 km² | -| Rondane | OpenStreetMap | `natural=mountain_range`, relasjon 8512563 | 1 496 km² | -| Hardangervidda | OpenStreetMap | `natural=mountain_range`, relasjon 8512565 | 4 381 km² | -| Dovrefjell | OpenStreetMap | `natural=mountain_range`, relasjon 8512562 | 4 550 km² | -| Trollheimen | Miljødirektoratet | Trollheimen landskapsvernområde (VV00000745) | 1 914 km² | -| Breheimen | Miljødirektoratet | Breheimen nasjonalpark (VV00002830) | 2 668 km² | -| Femundsmarka | Miljødirektoratet | Femundsmarka nasjonalpark (VV00001943) | 1 025 km² | -| Blåfjella–Skjækerfjella | Miljødirektoratet | Blåfjella-Skjækerfjella nasjonalpark (VV00001562) | 3 109 km² | -| Saltfjellet | Miljødirektoratet | Saltfjellet-Svartisen nasjonalpark (VV00000235) | 3 013 km² | -| Lyngsalpan | Miljødirektoratet | Lyngsalpan landskapsvernområde (VV00002271) | 1 661 km² | -| Setesdalsheiane | Miljødirektoratet | Setesdal Vesthei Ryfylkeheiane landskapsvernområde (VV00001937) | 3 732 km² | -| Romsdalsalpane | Håndlaget rektangel | 7,4–8,1 °Ø, 62,35–62,65 °N | 1 206 km² | -| Sunnmørsalpane | Håndlaget rektangel | 6,2–7,1 °Ø, 62,0–62,4 °N | 2 088 km² | -| Skarvheimen | Håndlaget rektangel | 7,5–8,4 °Ø, 60,6–61,0 °N | 2 184 km² | -| Lofoten | Håndlaget rektangel | 12,8–15,5 °Ø, 67,85–68,45 °N | 7 506 km² | +| Område | Kilde | Grense | Areal* | +| ----------------------- | ------------------- | --------------------------------------------------------------- | --------- | +| Jotunheimen | OpenStreetMap | `natural=mountain_range`, relasjon 8512564 | 1 830 km² | +| Rondane | OpenStreetMap | `natural=mountain_range`, relasjon 8512563 | 1 496 km² | +| Hardangervidda | OpenStreetMap | `natural=mountain_range`, relasjon 8512565 | 4 381 km² | +| Dovrefjell | OpenStreetMap | `natural=mountain_range`, relasjon 8512562 | 4 550 km² | +| Trollheimen | Miljødirektoratet | Trollheimen landskapsvernområde (VV00000745) | 1 914 km² | +| Breheimen | Miljødirektoratet | Breheimen nasjonalpark (VV00002830) | 2 668 km² | +| Femundsmarka | Miljødirektoratet | Femundsmarka nasjonalpark (VV00001943) | 1 025 km² | +| Blåfjella–Skjækerfjella | Miljødirektoratet | Blåfjella-Skjækerfjella nasjonalpark (VV00001562) | 3 109 km² | +| Saltfjellet | Miljødirektoratet | Saltfjellet-Svartisen nasjonalpark (VV00000235) | 3 013 km² | +| Lyngsalpan | Miljødirektoratet | Lyngsalpan landskapsvernområde (VV00002271) | 1 661 km² | +| Setesdalsheiane | Miljødirektoratet | Setesdal Vesthei Ryfylkeheiane landskapsvernområde (VV00001937) | 3 732 km² | +| Romsdalsalpane | Håndlaget rektangel | 7,4–8,1 °Ø, 62,35–62,65 °N | 1 206 km² | +| Sunnmørsalpane | Håndlaget rektangel | 6,2–7,1 °Ø, 62,0–62,4 °N | 2 088 km² | +| Skarvheimen | Håndlaget rektangel | 7,5–8,4 °Ø, 60,6–61,0 °N | 2 184 km² | +| Lofoten | Håndlaget rektangel | 12,8–15,5 °Ø, 67,85–68,45 °N | 7 506 km² | \* Arealet slik det ligger i tabellen, altså med 3 km utvidelse for grensene fra OSM og Miljødirektoratet. @@ -142,11 +158,11 @@ Andre kilder som ble vurdert: - **Forenklet:** Grensene fra OSM og Miljødirektoratet er forenklet til omtrent 100 m nøyaktighet med `ogr2ogr -simplify 0.001`, og koordinatene er rundet av til 5 desimaler. Det holder godt for et oversiktskart, og hele SQL-fila blir på rundt 65 kB. - **Utvidet med 3 km:** Mange DNT-hytter ligger rett utenfor verne- og fjellgrensene, nede i dalen eller ved veien inn. Gjendesheim og Spiterstulen er eksempler. Derfor utvider SQL-fila disse grensene med 3 km med `ST_Buffer(area::geography, 3000)`, der `geography` gjør at avstanden regnes i meter. 3 km ble valgt etter å ha sammenlignet antall hytter: - | | Gammelt rektangel | Grensen | + 2 km | + 3 km | + 5 km | - |---|---|---|---|---|---| - | Jotunheimen | 19 | 4 | 12 | 16 | 17 | - | Rondane | 7 | 1 | 11 | 11 | 11 | - | Hardangervidda | 26 | 9 | 12 | 15 | 18 | + | | Gammelt rektangel | Grensen | + 2 km | + 3 km | + 5 km | + | -------------- | ----------------- | ------- | ------ | ------ | ------ | + | Jotunheimen | 19 | 4 | 12 | 16 | 17 | + | Rondane | 7 | 1 | 11 | 11 | 11 | + | Hardangervidda | 26 | 9 | 12 | 15 | 18 | Hardangervidda har færre hytter enn før fordi det gamle rektangelet gikk langt utenfor selve vidda. diff --git a/server/authLimits.ts b/server/authLimits.ts index 0ed84d8..8d1c214 100644 --- a/server/authLimits.ts +++ b/server/authLimits.ts @@ -1,13 +1,17 @@ -import {GraphQLError} from 'graphql' +import { GraphQLError } from 'graphql' // In-memory guard for this single-process prototype; do not trust forwarded IP headers. -const attempts=new Map() -let globalWindow={start:0,count:0} -export function checkAuthAttempt(username:string,now=Date.now()){ - if(now-globalWindow.start>=60000)globalWindow={start:now,count:0} - if(++globalWindow.count>120)throw new GraphQLError('For mange innloggingsforsøk. Vent ett minutt og prøv igjen.') - for(const [key,value] of attempts)if(now-value.start>=900000)attempts.delete(key) - const key=username.trim().toLowerCase().slice(0,100) - const value=attempts.get(key)||{start:now,count:0} - attempts.set(key,value) - if(++value.count>20)throw new GraphQLError('For mange forsøk for dette brukernavnet. Vent 15 minutter og prøv igjen.') +const attempts = new Map() +let globalWindow = { start: 0, count: 0 } +export function checkAuthAttempt(username: string, now = Date.now()) { + if (now - globalWindow.start >= 60000) globalWindow = { start: now, count: 0 } + if (++globalWindow.count > 120) + throw new GraphQLError('For mange innloggingsforsøk. Vent ett minutt og prøv igjen.') + for (const [key, value] of attempts) if (now - value.start >= 900000) attempts.delete(key) + const key = username.trim().toLowerCase().slice(0, 100) + const value = attempts.get(key) || { start: now, count: 0 } + attempts.set(key, value) + if (++value.count > 20) + throw new GraphQLError( + 'For mange forsøk for dette brukernavnet. Vent 15 minutter og prøv igjen.', + ) } diff --git a/server/customRoutes.ts b/server/customRoutes.ts index e2bd021..f331ba1 100644 --- a/server/customRoutes.ts +++ b/server/customRoutes.ts @@ -1,126 +1,240 @@ import { GraphQLError } from 'graphql' import { pool } from './db.ts' -export type BuilderPoint={lon:number;lat:number} -type NetworkNode={id:string;xy:number[];ll:number[];neighbors:Map} -type LineRow={projected:{coordinates:number[][]};geographic:{coordinates:number[][]}} -type RingRow=LineRow & {id:string;length:number} -type Network={nodes:Map;sourceRouteId:string;sourceName:string;geojson:string;cycles?:Promise} -const networks = new Map>() -export function builderNetwork(routeId:string|null = null) { +export type BuilderPoint = { lon: number; lat: number } +type NetworkNode = { id: string; xy: number[]; ll: number[]; neighbors: Map } +type LineRow = { projected: { coordinates: number[][] }; geographic: { coordinates: number[][] } } +type RingRow = LineRow & { id: string; length: number } +type Network = { + nodes: Map + sourceRouteId: string + sourceName: string + geojson: string + cycles?: Promise +} +const networks = new Map>() +export function builderNetwork(routeId: string | null = null) { if (routeId && !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig kilderute.') const key = routeId || 'Bymarka' if (!networks.has(key)) { if (networks.size >= 4) networks.delete(networks.keys().next().value!) - networks.set(key,loadNetwork(routeId).catch(error => {networks.delete(key);throw error})) + networks.set( + key, + loadNetwork(routeId).catch((error) => { + networks.delete(key) + throw error + }), + ) } return networks.get(key)! } -async function loadNetwork(routeId:string|null):Promise { - const { rows: sources } = await pool.query(`SELECT id,name FROM norwegian_explorer_app.route_catalog +async function loadNetwork(routeId: string | null): Promise { + const { rows: sources } = await pool.query( + `SELECT id,name FROM norwegian_explorer_app.route_catalog WHERE (($1::text IS NOT NULL AND id=$1) OR ($1::text IS NULL AND name='Bymarka')) AND NOT EXISTS(SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=route_catalog.id) - UNION ALL SELECT id,name FROM norwegian_explorer_app.route_loops WHERE id=$1`,[routeId]) + UNION ALL SELECT id,name FROM norwegian_explorer_app.route_loops WHERE id=$1`, + [routeId], + ) if (!sources.length) throw new GraphQLError('Kilderuten finnes ikke lenger. Velg en annen rute.') const source = sources[0] - const { rows } = await pool.query(`WITH network AS ( + const { rows } = await pool.query( + `WITH network AS ( SELECT ST_Node(ST_UnaryUnion(ST_Collect(geom))) AS geom FROM (SELECT geom FROM norwegian_explorer_app.route_catalog WHERE id=$1 UNION ALL SELECT geom FROM norwegian_explorer_app.route_loops WHERE id=$1) source ), lines AS ( SELECT (ST_Dump(ST_Segmentize(geom,30))).geom AS geom FROM network ) SELECT ST_AsGeoJSON(geom)::json AS projected, - ST_AsGeoJSON(ST_Transform(geom,4326))::json AS geographic FROM lines`,[source.id]) + ST_AsGeoJSON(ST_Transform(geom,4326))::json AS geographic FROM lines`, + [source.id], + ) if (!rows.length) throw new GraphQLError('Denne ruten har ikke et tilgjengelig stinett.') - const pointCount=rows.reduce((total,row)=>total+row.projected.coordinates.length,0) - if(pointCount>250000)throw new GraphQLError('Stinettet er for stort for rutebyggeren. Velg en mindre rutevariant.') - const nodes = new Map() - const key = (point:number[]) => point.map(n => n.toFixed(6)).join(',') + const pointCount = rows.reduce((total, row) => total + row.projected.coordinates.length, 0) + if (pointCount > 250000) + throw new GraphQLError('Stinettet er for stort for rutebyggeren. Velg en mindre rutevariant.') + const nodes = new Map() + const key = (point: number[]) => point.map((n) => n.toFixed(6)).join(',') for (const row of rows) { const xy = row.projected.coordinates const ll = row.geographic.coordinates - for (let i=0;i row.geographic.coordinates) }) } + return { + nodes, + sourceRouteId: source.id, + sourceName: source.name, + geojson: JSON.stringify({ + type: 'MultiLineString', + coordinates: rows.map((row) => row.geographic.coordinates), + }), + } } -function shortestPath(nodes:Map, start:string, end:string) { - const distances = new Map([[start,0]]), previous = new Map(), visited = new Set() - const queue:[number,string][] = [[0,start]] - function push(entry:[number,string]) { +function shortestPath(nodes: Map, start: string, end: string) { + const distances = new Map([[start, 0]]), + previous = new Map(), + visited = new Set() + const queue: [number, string][] = [[0, start]] + function push(entry: [number, string]) { queue.push(entry) - let i=queue.length-1 - while(i>0){const parent=Math.floor((i-1)/2);if(queue[parent][0]<=entry[0])break;queue[i]=queue[parent];i=parent} - queue[i]=entry + let i = queue.length - 1 + while (i > 0) { + const parent = Math.floor((i - 1) / 2) + if (queue[parent][0] <= entry[0]) break + queue[i] = queue[parent] + i = parent + } + queue[i] = entry } function pop() { - const result=queue[0],last=queue.pop()! - if(queue.length){let i=0;queue[0]=last;while(true){let child=i*2+1;if(child>=queue.length)break;if(child+1=last[0])break;queue[i]=queue[child];i=child}queue[i]=last} + const result = queue[0], + last = queue.pop()! + if (queue.length) { + let i = 0 + queue[0] = last + while (true) { + let child = i * 2 + 1 + if (child >= queue.length) break + if (child + 1 < queue.length && queue[child + 1][0] < queue[child][0]) child++ + if (queue[child][0] >= last[0]) break + queue[i] = queue[child] + i = child + } + queue[i] = last + } return result } while (queue.length) { - const [distance,id] = pop() + const [distance, id] = pop() if (visited.has(id)) continue visited.add(id) if (id === end) { const path = [id] - while (path[path.length-1] !== start) path.push(previous.get(path[path.length-1])) + while (path[path.length - 1] !== start) path.push(previous.get(path[path.length - 1])) path.reverse() return { path, distance } } - for (const [neighbor,cost] of nodes.get(id)!.neighbors) { - const next = distance+cost - if (next < (distances.get(neighbor) ?? Infinity)) { distances.set(neighbor,next); previous.set(neighbor,id); push([next,neighbor]) } + for (const [neighbor, cost] of nodes.get(id)!.neighbors) { + const next = distance + cost + if (next < (distances.get(neighbor) ?? Infinity)) { + distances.set(neighbor, next) + previous.set(neighbor, id) + push([next, neighbor]) + } } } - throw new GraphQLError('Punktene ligger på adskilte deler av stinettet. Flytt et punkt til en sti som er koblet til de andre.') + throw new GraphQLError( + 'Punktene ligger på adskilte deler av stinettet. Flytt et punkt til en sti som er koblet til de andre.', + ) } -export async function planCustomRoute(points:BuilderPoint[], routeId:string|null = null) { - if (!Array.isArray(points) || points.length<2 || points.length>12 || points.some(p => !p || !Number.isFinite(p.lon) || !Number.isFinite(p.lat) || p.lon<-180 || p.lon>180 || p.lat<-90 || p.lat>90)) throw new GraphQLError('Velg 2–12 gyldige punkter på den valgte ruten.') +export async function planCustomRoute(points: BuilderPoint[], routeId: string | null = null) { + if ( + !Array.isArray(points) || + points.length < 2 || + points.length > 12 || + points.some( + (p) => + !p || + !Number.isFinite(p.lon) || + !Number.isFinite(p.lat) || + p.lon < -180 || + p.lon > 180 || + p.lat < -90 || + p.lat > 90, + ) + ) + throw new GraphQLError('Velg 2–12 gyldige punkter på den valgte ruten.') const network = await builderNetwork(routeId) - const { rows } = await pool.query(`SELECT ST_X(p) AS x,ST_Y(p) AS y FROM ( + const { rows } = await pool.query( + `SELECT ST_X(p) AS x,ST_Y(p) AS y FROM ( SELECT ST_Transform(ST_SetSRID(ST_MakePoint((v->>'lon')::float8,(v->>'lat')::float8),4326),25833) AS p FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS input(v,n) ORDER BY n - ) transformed`, [JSON.stringify(points)]) - const snapped = rows.map(point => { - let best:NetworkNode|undefined, distance = Infinity + ) transformed`, + [JSON.stringify(points)], + ) + const snapped = rows.map((point) => { + let best: NetworkNode | undefined, + distance = Infinity for (const node of network.nodes.values()) { - const d = Math.hypot(node.xy[0]-point.x,node.xy[1]-point.y) - if (d120) throw new GraphQLError('Et punkt er mer enn 120 meter fra de registrerte stiene. Flytt det nærmere en sti.') + if (!best || distance > 120) + throw new GraphQLError( + 'Et punkt er mer enn 120 meter fra de registrerte stiene. Flytt det nærmere en sti.', + ) return best }) - let lengthMeters=0 - const coordinates=[] - for (let i=1;i ({ lon: node.ll[0], lat: node.ll[1] })), } - if (lengthMeters<50) throw new GraphQLError('Velg punkter som gir en rute på minst 50 meter.') - return { sourceRouteId:network.sourceRouteId,sourceName:network.sourceName,geojson:JSON.stringify({type:'LineString',coordinates}),lengthMeters, - waypoints:snapped.map(node => ({lon:node.ll[0],lat:node.ll[1]})) } } -export async function suggestRoundTrips(start:BuilderPoint, desiredLengthMeters:number, routeId:string|null = null) { - if (!start || !Number.isFinite(start.lon) || !Number.isFinite(start.lat) || Math.abs(start.lon)>180 || Math.abs(start.lat)>90 || !Number.isFinite(desiredLengthMeters) || desiredLengthMeters<500 || desiredLengthMeters>50000) throw new GraphQLError('Velg et gyldig startpunkt og en lengde mellom 0,5 og 50 km.') - const network=await builderNetwork(routeId) - const {rows:[position]}=await pool.query('SELECT ST_X(p) AS x,ST_Y(p) AS y FROM (SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1,$2),4326),25833) p) point',[start.lon,start.lat]) - let startNode:NetworkNode|undefined,distance=Infinity - for(const node of network.nodes.values()){const d=Math.hypot(node.xy[0]-position.x,node.xy[1]-position.y);if(d120)throw new GraphQLError('Startpunktet må være innen 120 meter fra det valgte stinettet.') - if(!network.cycles) network.cycles=pool.query(`WITH source AS ( +export async function suggestRoundTrips( + start: BuilderPoint, + desiredLengthMeters: number, + routeId: string | null = null, +) { + if ( + !start || + !Number.isFinite(start.lon) || + !Number.isFinite(start.lat) || + Math.abs(start.lon) > 180 || + Math.abs(start.lat) > 90 || + !Number.isFinite(desiredLengthMeters) || + desiredLengthMeters < 500 || + desiredLengthMeters > 50000 + ) + throw new GraphQLError('Velg et gyldig startpunkt og en lengde mellom 0,5 og 50 km.') + const network = await builderNetwork(routeId) + const { + rows: [position], + } = await pool.query( + 'SELECT ST_X(p) AS x,ST_Y(p) AS y FROM (SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1,$2),4326),25833) p) point', + [start.lon, start.lat], + ) + let startNode: NetworkNode | undefined, + distance = Infinity + for (const node of network.nodes.values()) { + const d = Math.hypot(node.xy[0] - position.x, node.xy[1] - position.y) + if (d < distance) { + distance = d + startNode = node + } + } + if (!startNode || distance > 120) + throw new GraphQLError('Startpunktet må være innen 120 meter fra det valgte stinettet.') + if (!network.cycles) + network.cycles = pool + .query( + `WITH source AS ( SELECT geom FROM norwegian_explorer_app.route_catalog WHERE id=$1 UNION ALL SELECT geom FROM norwegian_explorer_app.route_loops WHERE id=$1 ), network AS (SELECT ST_Node(ST_UnaryUnion(ST_Collect(geom))) geom FROM source), @@ -128,40 +242,101 @@ export async function suggestRoundTrips(start:BuilderPoint, desiredLengthMeters: rings AS (SELECT ST_ExteriorRing(geom) geom FROM polygons) SELECT md5(ST_AsEWKB(ST_Normalize(geom))::text) id,ST_AsGeoJSON(geom)::json projected, ST_AsGeoJSON(ST_Transform(geom,4326))::json geographic,ST_Length(geom) length - FROM rings WHERE ST_Length(geom) BETWEEN 200 AND 50000`,[network.sourceRouteId]).then(result=>result.rows).catch(error=>{network.cycles=undefined;throw error}) - const rings=await network.cycles - const candidates=[] - for(const ring of rings){ - let anchorIndex=0,near=Infinity - const xy=ring.projected.coordinates.slice(0,-1) - const ll=ring.geographic.coordinates.slice(0,-1) - let directLoop=null - for(let i=0;i=0&&t<=1&&Math.hypot(a[0]+t*dx-startNode.xy[0],a[1]+t*dy-startNode.xy[1])<0.0001){directLoop=[startNode.ll,...ll.slice(i+1),...ll.slice(0,i+1),startNode.ll];break} + FROM rings WHERE ST_Length(geom) BETWEEN 200 AND 50000`, + [network.sourceRouteId], + ) + .then((result) => result.rows) + .catch((error) => { + network.cycles = undefined + throw error + }) + const rings = await network.cycles + const candidates = [] + for (const ring of rings) { + let anchorIndex = 0, + near = Infinity + const xy = ring.projected.coordinates.slice(0, -1) + const ll = ring.geographic.coordinates.slice(0, -1) + let directLoop = null + for (let i = 0; i < xy.length; i++) { + const a = xy[i], + b = xy[(i + 1) % xy.length], + dx = b[0] - a[0], + dy = b[1] - a[1] + const t = + ((startNode.xy[0] - a[0]) * dx + (startNode.xy[1] - a[1]) * dy) / (dx * dx + dy * dy) + if ( + t >= 0 && + t <= 1 && + Math.hypot(a[0] + t * dx - startNode.xy[0], a[1] + t * dy - startNode.xy[1]) < 0.0001 + ) { + directLoop = [startNode.ll, ...ll.slice(i + 1), ...ll.slice(0, i + 1), startNode.ll] + break + } } - for(let i=0;in.toFixed(6)).join(',')) - if(anchor)candidates.push({ring,anchor,anchorIndex,directLoop,score:Math.abs(ring.length+2*(directLoop?0:near)-desiredLengthMeters)}) + for (let i = 0; i < xy.length; i++) { + const d = Math.hypot(xy[i][0] - startNode.xy[0], xy[i][1] - startNode.xy[1]) + if (d < near) { + near = d + anchorIndex = i + } + } + const anchor = directLoop + ? startNode + : network.nodes.get(xy[anchorIndex].map((n) => n.toFixed(6)).join(',')) + if (anchor) + candidates.push({ + ring, + anchor, + anchorIndex, + directLoop, + score: Math.abs(ring.length + 2 * (directLoop ? 0 : near) - desiredLengthMeters), + }) } - const proposals=[] - for(const {ring,anchor,anchorIndex,directLoop} of candidates.sort((a,b)=>a.score-b.score).slice(0,40)){ + const proposals = [] + for (const { ring, anchor, anchorIndex, directLoop } of candidates + .sort((a, b) => a.score - b.score) + .slice(0, 40)) { let connector - try{connector=shortestPath(network.nodes,startNode.id,anchor.id)}catch{continue} - const lengthMeters=ring.length+2*connector.distance - if(Math.abs(lengthMeters-desiredLengthMeters)>Math.max(300,desiredLengthMeters*0.4))continue - const access=connector.path.map(id=>network.nodes.get(id)!.ll) - const ringCoords=ring.geographic.coordinates.slice(0,-1) - const loop=directLoop || [...ringCoords.slice(anchorIndex),...ringCoords.slice(0,anchorIndex),ringCoords[anchorIndex]] - const coordinates=[] - for(const point of access)coordinates.push(point) - for(const point of loop.slice(1))coordinates.push(point) - for(const point of access.slice(0,-1).reverse())coordinates.push(point) - const [lon,lat]=startNode.ll - proposals.push({id:ring.id,sourceRouteId:network.sourceRouteId,sourceName:network.sourceName, - geojson:JSON.stringify({type:'LineString',coordinates}),lengthMeters,connectorMeters:2*connector.distance, - differenceMeters:lengthMeters-desiredLengthMeters,waypoints:[{lon,lat},{lon,lat}]}) + try { + connector = shortestPath(network.nodes, startNode.id, anchor.id) + } catch { + continue + } + const lengthMeters = ring.length + 2 * connector.distance + if (Math.abs(lengthMeters - desiredLengthMeters) > Math.max(300, desiredLengthMeters * 0.4)) + continue + const access = connector.path.map((id) => network.nodes.get(id)!.ll) + const ringCoords = ring.geographic.coordinates.slice(0, -1) + const loop = directLoop || [ + ...ringCoords.slice(anchorIndex), + ...ringCoords.slice(0, anchorIndex), + ringCoords[anchorIndex], + ] + const coordinates = [] + for (const point of access) coordinates.push(point) + for (const point of loop.slice(1)) coordinates.push(point) + for (const point of access.slice(0, -1).reverse()) coordinates.push(point) + const [lon, lat] = startNode.ll + proposals.push({ + id: ring.id, + sourceRouteId: network.sourceRouteId, + sourceName: network.sourceName, + geojson: JSON.stringify({ type: 'LineString', coordinates }), + lengthMeters, + connectorMeters: 2 * connector.distance, + differenceMeters: lengthMeters - desiredLengthMeters, + waypoints: [ + { lon, lat }, + { lon, lat }, + ], + }) } - return proposals.sort((a,b)=>Math.abs(a.differenceMeters)-Math.abs(b.differenceMeters)||a.connectorMeters-b.connectorMeters).slice(0,3) + return proposals + .sort( + (a, b) => + Math.abs(a.differenceMeters) - Math.abs(b.differenceMeters) || + a.connectorMeters - b.connectorMeters, + ) + .slice(0, 3) } diff --git a/server/db.ts b/server/db.ts index 69c5681..a6a811d 100644 --- a/server/db.ts +++ b/server/db.ts @@ -4,14 +4,17 @@ import { userInfo } from 'node:os' import pg from 'pg' -if (!process.env.DATABASE_URL) console.warn('DATABASE_URL er ikke satt i .env, så backend kobler til en lokal database.') +if (!process.env.DATABASE_URL) + console.warn('DATABASE_URL er ikke satt i .env, så backend kobler til en lokal database.') export const pool = new pg.Pool({ - ...(process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } : { - host: process.env.PGHOST || '/tmp', - user: process.env.PGUSER || userInfo().username, - database: process.env.PGDATABASE || 'norwegian_explorer', - }), + ...(process.env.DATABASE_URL + ? { connectionString: process.env.DATABASE_URL } + : { + host: process.env.PGHOST || '/tmp', + user: process.env.PGUSER || userInfo().username, + database: process.env.PGDATABASE || 'norwegian_explorer', + }), max: 4, connectionTimeoutMillis: 5000, statement_timeout: 10000, diff --git a/server/elevation.ts b/server/elevation.ts index 8712fd3..6305989 100644 --- a/server/elevation.ts +++ b/server/elevation.ts @@ -1,76 +1,149 @@ -import {createHash} from 'node:crypto' -import {GraphQLError} from 'graphql' +import { createHash } from 'node:crypto' +import { GraphQLError } from 'graphql' -type SamplePoint={distanceMeters:number;lon:number;lat:number} -type Sample={points:SamplePoint[];lengthMeters:number;sampleIntervalMeters:number} -type HeightResponse={koordsys:number;punkter:{x:number;y:number;z:number|null}[]} -const cache=new Map}>() -const endpoint='https://ws.geonorge.no/hoydedata/v1/punkt' -function distance(a:number[],b:number[]){const rad=Math.PI/180;const dlat=(b[1]-a[1])*rad,dlon=(b[0]-a[0])*rad;const h=Math.sin(dlat/2)**2+Math.cos(a[1]*rad)*Math.cos(b[1]*rad)*Math.sin(dlon/2)**2;return 6371000*2*Math.atan2(Math.sqrt(h),Math.sqrt(Math.max(0,1-h)))} +type SamplePoint = { distanceMeters: number; lon: number; lat: number } +type Sample = { points: SamplePoint[]; lengthMeters: number; sampleIntervalMeters: number } +type HeightResponse = { koordsys: number; punkter: { x: number; y: number; z: number | null }[] } +const cache = new Map }>() +const endpoint = 'https://ws.geonorge.no/hoydedata/v1/punkt' +function distance(a: number[], b: number[]) { + const rad = Math.PI / 180 + const dlat = (b[1] - a[1]) * rad, + dlon = (b[0] - a[0]) * rad + const h = + Math.sin(dlat / 2) ** 2 + Math.cos(a[1] * rad) * Math.cos(b[1] * rad) * Math.sin(dlon / 2) ** 2 + return 6371000 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(Math.max(0, 1 - h))) +} -export function sampleRoute(geojson:string):Sample { - if(typeof geojson!=='string'||geojson.length>5000000)throw new GraphQLError('Kartlinjen er for stor for høydeprofil.') - let geometry:{type?:string;coordinates?:number[][]}|null - try{geometry=JSON.parse(geojson)}catch{throw new GraphQLError('Ugyldig kartlinje.')} - if(!geometry || geometry.type!=='LineString'||!Array.isArray(geometry.coordinates)||geometry.coordinates.length<2||geometry.coordinates.length>100000)throw new GraphQLError('Høydeprofil krever én sammenhengende rute.') - const coordinates=geometry.coordinates! - if(coordinates.some(p=>!Array.isArray(p)||!Number.isFinite(p[0])||!Number.isFinite(p[1])||Math.abs(p[0])>180||Math.abs(p[1])>90))throw new GraphQLError('Ruten har ugyldige koordinater.') - const cumulative=[0] - for(let i=1;i 5000000) + throw new GraphQLError('Kartlinjen er for stor for høydeprofil.') + let geometry: { type?: string; coordinates?: number[][] } | null + try { + geometry = JSON.parse(geojson) + } catch { + throw new GraphQLError('Ugyldig kartlinje.') + } + if ( + !geometry || + geometry.type !== 'LineString' || + !Array.isArray(geometry.coordinates) || + geometry.coordinates.length < 2 || + geometry.coordinates.length > 100000 + ) + throw new GraphQLError('Høydeprofil krever én sammenhengende rute.') + const coordinates = geometry.coordinates! + if ( + coordinates.some( + (p) => + !Array.isArray(p) || + !Number.isFinite(p[0]) || + !Number.isFinite(p[1]) || + Math.abs(p[0]) > 180 || + Math.abs(p[1]) > 90, + ) + ) + throw new GraphQLError('Ruten har ugyldige koordinater.') + const cumulative = [0] + for (let i = 1; i < coordinates.length; i++) + cumulative.push(cumulative[i - 1] + distance(coordinates[i - 1], coordinates[i])) + const length = cumulative.at(-1)! + if (length < 1) throw new GraphQLError('Ruten er for kort for en høydeprofil.') + const count = Math.min(200, Math.max(2, Math.ceil(length / 50) + 1)), + points = [] + let segment = 1 + for (let i = 0; i < count; i++) { + const meters = (i * length) / (count - 1) + while (segment < cumulative.length - 1 && cumulative[segment] < meters) segment++ + const a = coordinates[segment - 1], + b = coordinates[segment], + span = cumulative[segment] - cumulative[segment - 1] + const ratio = span ? (meters - cumulative[segment - 1]) / span : 0 + points.push({ + distanceMeters: meters, + lon: a[0] + (b[0] - a[0]) * ratio, + lat: a[1] + (b[1] - a[1]) * ratio, + }) } - return {points,lengthMeters:length,sampleIntervalMeters:length/(count-1)} + return { points, lengthMeters: length, sampleIntervalMeters: length / (count - 1) } } -export function summarizeElevation(sample:Sample,heights:(number|null)[]) { - const points=sample.points.map((point,i)=>({...point,elevation:Number.isFinite(heights[i])?heights[i]:null})) - const valid=points.filter((p):p is (typeof points)[number]&{elevation:number}=>p.elevation!=null),complete=valid.length===points.length - let ascent=0,descent=0 - if(complete)for(let i=1;ip.elevation)):null, - maxElevation:valid.length?Math.max(...valid.map(p=>p.elevation)):null, - complete,missingCount:points.length-valid.length,source:'Kartverkets høydedata-API'} +export function summarizeElevation(sample: Sample, heights: (number | null)[]) { + const points = sample.points.map((point, i) => ({ + ...point, + elevation: Number.isFinite(heights[i]) ? heights[i] : null, + })) + const valid = points.filter( + (p): p is (typeof points)[number] & { elevation: number } => p.elevation != null, + ), + complete = valid.length === points.length + let ascent = 0, + descent = 0 + if (complete) + for (let i = 1; i < points.length; i++) { + const delta = points[i].elevation! - points[i - 1].elevation! + ascent += Math.max(0, delta) + descent += Math.max(0, -delta) + } + return { + ...sample, + points, + ascentMeters: complete ? ascent : null, + descentMeters: complete ? descent : null, + minElevation: valid.length ? Math.min(...valid.map((p) => p.elevation)) : null, + maxElevation: valid.length ? Math.max(...valid.map((p) => p.elevation)) : null, + complete, + missingCount: points.length - valid.length, + source: 'Kartverkets høydedata-API', + } } -export async function elevationProfile(geojson:string,fetcher:typeof fetch=fetch) { - const sample=sampleRoute(geojson) - const key=createHash('sha256').update(JSON.stringify(sample.points)).digest('hex') - const existing=cache.get(key) - if(existing&&existing.expires>Date.now())return existing.value - const heights=new Array(sample.points.length).fill(null) - const signal=AbortSignal.timeout(20000) - const batches:number[]=[] - for(let i=0;i[p.lon,p.lat]))) - const response=await fetcher(url,{signal}) - if(!response.ok)throw new Error('Høydetjenesten svarte ikke.') - const result=await response.json() as HeightResponse - if(result.koordsys!==4326||!Array.isArray(result.punkter))throw new Error('Uventet svar fra høydetjenesten.') - for(let i=0;iNumber.isFinite(p.x)&&Number.isFinite(p.y)&&Math.abs(p.x-point.lon)<0.000001&&Math.abs(p.y-point.lat)<0.000001) - if(match&&Number.isFinite(match.z))heights[offset+i]=match.z +export async function elevationProfile(geojson: string, fetcher: typeof fetch = fetch) { + const sample = sampleRoute(geojson) + const key = createHash('sha256').update(JSON.stringify(sample.points)).digest('hex') + const existing = cache.get(key) + if (existing && existing.expires > Date.now()) return existing.value + const heights = new Array(sample.points.length).fill(null) + const signal = AbortSignal.timeout(20000) + const batches: number[] = [] + for (let i = 0; i < sample.points.length; i += 50) batches.push(i) + let next = 0 + async function worker() { + while (next < batches.length) { + const offset = batches[next++], + batch = sample.points.slice(offset, offset + 50) + const url = new URL(endpoint) + url.searchParams.set('koordsys', '4326') + url.searchParams.set('punkter', JSON.stringify(batch.map((p) => [p.lon, p.lat]))) + const response = await fetcher(url, { signal }) + if (!response.ok) throw new Error('Høydetjenesten svarte ikke.') + const result = (await response.json()) as HeightResponse + if (result.koordsys !== 4326 || !Array.isArray(result.punkter)) + throw new Error('Uventet svar fra høydetjenesten.') + for (let i = 0; i < batch.length; i++) { + const point = batch[i] + const match = result.punkter.find( + (p) => + Number.isFinite(p.x) && + Number.isFinite(p.y) && + Math.abs(p.x - point.lon) < 0.000001 && + Math.abs(p.y - point.lat) < 0.000001, + ) + if (match && Number.isFinite(match.z)) heights[offset + i] = match.z } } } - try{await Promise.all([worker(),worker()])}catch{throw new GraphQLError('Høydetjenesten er utilgjengelig akkurat nå. Ruten kan fortsatt brukes og lagres; prøv høydeprofilen igjen senere.')} - if(heights.every(value=>value==null))throw new GraphQLError('Ingen høydedata er tilgjengelige for denne ruten.') - const result=summarizeElevation(sample,heights) - if(cache.size>=50)cache.delete(cache.keys().next().value!) - cache.set(key,{expires:Date.now()+3600000,value:result}) + try { + await Promise.all([worker(), worker()]) + } catch { + throw new GraphQLError( + 'Høydetjenesten er utilgjengelig akkurat nå. Ruten kan fortsatt brukes og lagres; prøv høydeprofilen igjen senere.', + ) + } + if (heights.every((value) => value == null)) + throw new GraphQLError('Ingen høydedata er tilgjengelige for denne ruten.') + const result = summarizeElevation(sample, heights) + if (cache.size >= 50) cache.delete(cache.keys().next().value!) + cache.set(key, { expires: Date.now() + 3600000, value: result }) return result } diff --git a/server/index.ts b/server/index.ts index 83b5928..3753492 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,22 +4,75 @@ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } fr import { promisify } from 'node:util' import { GraphQLError } from 'graphql' import { createSchema, createYoga } from 'graphql-yoga' -import type {Pool,PoolClient} from 'pg' -import type {BuilderPoint} from './customRoutes.ts' +import type { Pool, PoolClient } from 'pg' +import type { BuilderPoint } from './customRoutes.ts' import { pool } from './db.ts' import { mountainResolvers, mountainTypeDefs } from './server.ts' import { builderNetwork, planCustomRoute, suggestRoundTrips } from './customRoutes.ts' import { elevationProfile } from './elevation.ts' import { routeStops, nearbyFacilityRouteIds } from './routeStops.ts' -import {checkAuthAttempt} from './authLimits.ts' -import {requestSafety} from './requestSafety.ts' +import { checkAuthAttempt } from './authLimits.ts' +import { requestSafety } from './requestSafety.ts' const schemaName = process.env.DATA_SCHEMA || 'turogfriluftsruter_1b2541ab00554564ae88dabc8bb90c02' if (!/^[a-z_][a-z0-9_]*$/.test(schemaName)) throw new Error('Invalid DATA_SCHEMA') -type AppUser={id:string;username:string;email:string|null;phone:string|null;avatar:string|null;home_municipality:string|null} -type AuthUser=AppUser & {password_hash:Buffer;password_salt:Buffer} -type ApiArgs={query:string;code:string|null;token:string;username:string;password:string;name:string;number:string;geojson:string;search:string;county:string|null;municipality:string|null;facility:string|null;difficulty:string|null;routeType:string|null;west:number;south:number;east:number;north:number;offset:number;limit:number;minLength:number|null;maxLength:number|null;routeId:string|null;id:string|null;points:BuilderPoint[];start:BuilderPoint;desiredLengthMeters:number;description:string;stopIds:string[];roundTrip:{start:BuilderPoint;desiredLengthMeters:number;proposalId:string}|null;currentPassword:string;newPassword:string;month:string;target:number|null;completedOn:string|null;diaryNote:string|null;rating:number|null;status:'PLANNED'|'COMPLETED'|null;email:string|null;phone:string|null;avatar:string|null} -const scrypt = promisify(scryptCallback) as (password:string,salt:Buffer,length:number)=>Promise +type AppUser = { + id: string + username: string + email: string | null + phone: string | null + avatar: string | null + home_municipality: string | null +} +type AuthUser = AppUser & { password_hash: Buffer; password_salt: Buffer } +type ApiArgs = { + query: string + code: string | null + token: string + username: string + password: string + name: string + number: string + geojson: string + search: string + county: string | null + municipality: string | null + facility: string | null + difficulty: string | null + routeType: string | null + west: number + south: number + east: number + north: number + offset: number + limit: number + minLength: number | null + maxLength: number | null + routeId: string | null + id: string | null + points: BuilderPoint[] + start: BuilderPoint + desiredLengthMeters: number + description: string + stopIds: string[] + roundTrip: { start: BuilderPoint; desiredLengthMeters: number; proposalId: string } | null + currentPassword: string + newPassword: string + month: string + target: number | null + completedOn: string | null + diaryNote: string | null + rating: number | null + status: 'PLANNED' | 'COMPLETED' | null + email: string | null + phone: string | null + avatar: string | null +} +const scrypt = promisify(scryptCallback) as ( + password: string, + salt: Buffer, + length: number, +) => Promise const appSchemaSetup = pool.query(` CREATE SCHEMA IF NOT EXISTS norwegian_explorer_app; CREATE TABLE IF NOT EXISTS norwegian_explorer_app.users ( @@ -87,13 +140,16 @@ const appSchemaSetup = pool.query(` ALTER TABLE norwegian_explorer_app.custom_routes ADD COLUMN IF NOT EXISTS smart_plan jsonb; ALTER TABLE norwegian_explorer_app.custom_routes ADD COLUMN IF NOT EXISTS stops jsonb NOT NULL DEFAULT '[]'::jsonb; `) -const appSchemaReady = appSchemaSetup.catch(async error => { +const appSchemaReady = appSchemaSetup.catch(async (error) => { console.error('Kunne ikke klargjøre appens tabeller:', error.message) const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.users') AS users") if (!rows[0].users) throw error - const required=['sessions','favorites','user_routes','user_goals','custom_routes'] - const result=await pool.query<{ready:boolean}>("SELECT bool_and(to_regclass('norwegian_explorer_app.' || name) IS NOT NULL) AS ready FROM unnest($1::text[]) name",[required]) - if(!result.rows[0].ready)throw error + const required = ['sessions', 'favorites', 'user_routes', 'user_goals', 'custom_routes'] + const result = await pool.query<{ ready: boolean }>( + "SELECT bool_and(to_regclass('norwegian_explorer_app.' || name) IS NOT NULL) AS ready FROM unnest($1::text[]) name", + [required], + ) + if (!result.rows[0].ready) throw error console.error('Tabellene finnes allerede, så backend fortsetter med dem som de er.') }) // Kolonnen for «Mitt sted» kan mangle hvis den ikke er lagt til av eieren av tabellen ennå @@ -101,57 +157,84 @@ const appSchemaReady = appSchemaSetup.catch(async error => { const hasHomeMunicipality = appSchemaReady.then(async () => { const { rowCount } = await pool.query(`SELECT 1 FROM information_schema.columns WHERE table_schema='norwegian_explorer_app' AND table_name='users' AND column_name='home_municipality'`) - if (!rowCount) console.error('Kolonnen users.home_municipality mangler. Kjør server/sql/app/home_municipality.sql for å slå på stedsvalg.') - return (rowCount||0)>0 + if (!rowCount) + console.error( + 'Kolonnen users.home_municipality mangler. Kjør server/sql/app/home_municipality.sql for å slå på stedsvalg.', + ) + return (rowCount || 0) > 0 }) /** Kolonneuttrykket for brukerens sted, eller NULL hvis kolonnen ikke finnes i databasen */ -async function homeColumn(prefix:string = '') { +async function homeColumn(prefix: string = '') { return (await hasHomeMunicipality) ? `${prefix}home_municipality` : 'NULL AS home_municipality' } const routeCatalogReady = appSchemaReady.then(async () => { - const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") - if (!rows[0].catalog) throw new Error('Route catalog is missing. Run scripts/create-route-catalog.sql first.') + const { rows } = await pool.query( + "SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog", + ) + if (!rows[0].catalog) + throw new Error('Route catalog is missing. Run scripts/create-route-catalog.sql first.') }) // Får ikke databasebrukeren lage tabellene over, skal bare innlogging og ruter feile, ikke hele serveren -appSchemaReady.catch(error => console.error('Kunne ikke klargjøre appens tabeller:', error.message)) -routeCatalogReady.catch(error => console.error(error.message)) -hasHomeMunicipality.catch(()=>undefined) +appSchemaReady.catch((error) => + console.error('Kunne ikke klargjøre appens tabeller:', error.message), +) +routeCatalogReady.catch((error) => console.error(error.message)) +hasHomeMunicipality.catch(() => undefined) function validateEmail(email: string | null) { const value = email?.trim().toLowerCase() || '' - if (value.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.') + if (value.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) + throw new GraphQLError('Skriv inn en gyldig e-postadresse.') return value } -function validateCredentials(username:string, password:string) { +function validateCredentials(username: string, password: string) { const normalized = username.trim() - if (!/^[\p{L}\p{N}_.-]{3,30}$/u.test(normalized)) throw new GraphQLError('Brukernavnet må være 3–30 tegn og kan inneholde bokstaver, tall, punktum, bindestrek og understrek.') - if (password.length < 10 || password.length > 200) throw new GraphQLError('Passordet må være minst 10 tegn.') + if (!/^[\p{L}\p{N}_.-]{3,30}$/u.test(normalized)) + throw new GraphQLError( + 'Brukernavnet må være 3–30 tegn og kan inneholde bokstaver, tall, punktum, bindestrek og understrek.', + ) + if (password.length < 10 || password.length > 200) + throw new GraphQLError('Passordet må være minst 10 tegn.') return normalized } -async function passwordHash(password:string, salt:Buffer) { return Buffer.from(await scrypt(password, salt, 64)) } -function tokenHash(token:string) { return createHash('sha256').update(token).digest() } -async function createSession(client:Pool|PoolClient, userId:string) { +async function passwordHash(password: string, salt: Buffer) { + return Buffer.from(await scrypt(password, salt, 64)) +} +function tokenHash(token: string) { + return createHash('sha256').update(token).digest() +} +async function createSession(client: Pool | PoolClient, userId: string) { const token = randomBytes(32).toString('base64url') - await client.query("INSERT INTO norwegian_explorer_app.sessions(token_hash,user_id,expires_at) VALUES ($1,$2,now()+(30 * interval '1 day'))", [tokenHash(token), userId]) + await client.query( + "INSERT INTO norwegian_explorer_app.sessions(token_hash,user_id,expires_at) VALUES ($1,$2,now()+(30 * interval '1 day'))", + [tokenHash(token), userId], + ) return token } -async function userFromToken(token:string) { +async function userFromToken(token: string) { if (!token || token.length > 200) return null await appSchemaReady - const { rows } = await pool.query(`SELECT u.id,u.username,u.email,u.phone,u.avatar,${await homeColumn('u.')} FROM norwegian_explorer_app.sessions s + const { rows } = await pool.query( + `SELECT u.id,u.username,u.email,u.phone,u.avatar,${await homeColumn('u.')} FROM norwegian_explorer_app.sessions s JOIN norwegian_explorer_app.users u ON u.id=s.user_id - WHERE s.token_hash=$1 AND s.expires_at>now()`, [tokenHash(token)]) + WHERE s.token_hash=$1 AND s.expires_at>now()`, + [tokenHash(token)], + ) return rows[0] || null } -async function profileFor(user:AppUser) { +async function profileFor(user: AppUser) { await routeCatalogReady - const { rows } = await pool.query(`SELECT coalesce(c.id,f.route_id) AS id, + const { rows } = await pool.query( + `SELECT coalesce(c.id,f.route_id) AS id, coalesce(c.name,f.route_name) AS name,coalesce(c.number,f.route_number) AS number, f.created_at::text AS "createdAt" FROM norwegian_explorer_app.favorites f LEFT JOIN norwegian_explorer_app.route_catalog c ON c.id=f.route_id - WHERE f.user_id=$1 ORDER BY f.created_at DESC`, [user.id]) - const { rows: trips } = await pool.query(` + WHERE f.user_id=$1 ORDER BY f.created_at DESC`, + [user.id], + ) + const { rows: trips } = await pool.query( + ` WITH routes AS ( SELECT id,length_meters FROM norwegian_explorer_app.route_catalog UNION ALL SELECT id,length_meters FROM norwegian_explorer_app.route_loops @@ -160,23 +243,45 @@ async function profileFor(user:AppUser) { t.updated_at::text AS "updatedAt",r.length_meters AS "lengthMeters", t.completed_on::text AS "completedOn",t.diary_note AS "diaryNote",t.rating FROM norwegian_explorer_app.user_routes t LEFT JOIN routes r ON r.id=t.route_id - WHERE t.user_id=$1 ORDER BY t.updated_at DESC`, [user.id]) - const { rows: counties } = await pool.query(`SELECT count(DISTINCT a.county_number)::int AS count + WHERE t.user_id=$1 ORDER BY t.updated_at DESC`, + [user.id], + ) + const { rows: counties } = await pool.query( + `SELECT count(DISTINCT a.county_number)::int AS count FROM norwegian_explorer_app.user_routes t JOIN norwegian_explorer_app.route_areas a ON a.route_id=t.route_id - WHERE t.user_id=$1 AND t.status='COMPLETED'`, [user.id]) - const completed = trips.filter(trip => trip.status === 'COMPLETED') - const { rows: goals } = await pool.query('SELECT month,target FROM norwegian_explorer_app.user_goals WHERE user_id=$1 ORDER BY month DESC', [user.id]) - return { username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, homeMunicipality:await municipalityByCode(user.home_municipality), favorites: rows, - trips, goals, stats: { completedCount: completed.length, plannedCount: trips.length-completed.length, - totalLengthMeters: completed.reduce((sum,trip) => sum+(trip.lengthMeters || 0),0), countyCount: counties[0].count } } + WHERE t.user_id=$1 AND t.status='COMPLETED'`, + [user.id], + ) + const completed = trips.filter((trip) => trip.status === 'COMPLETED') + const { rows: goals } = await pool.query( + 'SELECT month,target FROM norwegian_explorer_app.user_goals WHERE user_id=$1 ORDER BY month DESC', + [user.id], + ) + return { + username: user.username, + email: user.email, + phone: user.phone, + avatar: user.avatar, + homeMunicipality: await municipalityByCode(user.home_municipality), + favorites: rows, + trips, + goals, + stats: { + completedCount: completed.length, + plannedCount: trips.length - completed.length, + totalLengthMeters: completed.reduce((sum, trip) => sum + (trip.lengthMeters || 0), 0), + countyCount: counties[0].count, + }, + } } // Kartutsnitt rundt turene i en kommune, slik at kartet på Utforsk kan starte der. // Vi har ikke kommunegrensene i databasen, så utsnittet regnes ut fra rutene som ligger i kommunen. // Svært lange ruter (over 30 km) strekker seg ofte langt ut av kommunen, så de tas bare med // hvis kommunen ikke har kortere ruter. Gir null hvis kommunen ikke har noen ruter. -async function municipalityBbox(code:string) { +async function municipalityBbox(code: string) { await routeCatalogReady - const { rows } = await pool.query(` + const { rows } = await pool.query( + ` WITH routes AS ( SELECT c.geom, c.length_meters FROM norwegian_explorer_app.route_areas a JOIN norwegian_explorer_app.route_catalog c ON c.id=a.route_id @@ -185,24 +290,34 @@ async function municipalityBbox(code:string) { SELECT geom FROM routes WHERE length_meters<=30000 UNION ALL SELECT geom FROM routes WHERE NOT EXISTS (SELECT 1 FROM routes WHERE length_meters<=30000) ), extent AS (SELECT ST_Transform(ST_SetSRID(ST_Extent(geom)::geometry,25833),4326) AS box FROM chosen) - SELECT CASE WHEN box IS NULL THEN NULL ELSE ARRAY[ST_XMin(box),ST_YMin(box),ST_XMax(box),ST_YMax(box)]::float8[] END AS bbox FROM extent`, [code]) + SELECT CASE WHEN box IS NULL THEN NULL ELSE ARRAY[ST_XMin(box),ST_YMin(box),ST_XMax(box),ST_YMax(box)]::float8[] END AS bbox FROM extent`, + [code], + ) return rows[0]?.bbox ?? null } -async function municipalityByCode(code:string|null) { +async function municipalityByCode(code: string | null) { if (!code) return null await routeCatalogReady - const { rows } = await pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name - FROM norwegian_explorer_app.route_areas WHERE municipality_number=$1 GROUP BY municipality_number`, [code]) + const { rows } = await pool.query( + `SELECT municipality_number AS code,min(municipality_name) AS name + FROM norwegian_explorer_app.route_areas WHERE municipality_number=$1 GROUP BY municipality_number`, + [code], + ) return rows[0] || null } -async function requireUser(token:string) { +async function requireUser(token: string) { const user = await userFromToken(token) if (!user) throw new GraphQLError('Du må logge inn.') return user } // Filtre i routes som kan ha flere verdier. Innen ett filter er det «eller» (Enkel eller Middels), // for steder langs turen er det «og» (turen må ha både hytte og utsiktspunkt i nærheten). -type RouteListArgs = { difficulty: string[] | null; routeType: string[] | null; facility: string[] | null; sort: RouteSort } +type RouteListArgs = { + difficulty: string[] | null + routeType: string[] | null + facility: string[] | null + sort: RouteSort +} type RouteSort = 'NAME' | 'SHORTEST' | 'LONGEST' | 'POPULAR' // ORDER BY for hver sortering. Teksten settes rett inn i SQL, men kommer bare fra denne lista (ikke fra brukeren). // key til slutt gjør rekkefølgen entydig, så pagineringen ikke hopper over eller gjentar turer. @@ -212,11 +327,14 @@ const ROUTE_SORT_ORDER: Record = { LONGEST: '"maxLengthMeters" DESC, name, key', POPULAR: '"favoriteCount" DESC, name, key', } -const validFilterList = (list: string[] | null) => list == null || (list.length <= 12 && list.every(item => typeof item === 'string' && item.length <= 40)) +const validFilterList = (list: string[] | null) => + list == null || + (list.length <= 12 && list.every((item) => typeof item === 'string' && item.length <= 40)) const schema = createSchema({ // Fjellområdene og turpunktene ligger i server/server.ts og legges til her - typeDefs: [` + typeDefs: [ + ` type Route { id: ID! name: String! @@ -296,21 +414,58 @@ const schema = createSchema({ saveCustomRoute(token: String!, id: ID, name: String!, description: String!, points: [BuilderPointInput!]!, routeId: ID, roundTrip: RoundTripInput, stopIds: [ID!] = []): CustomRoute! deleteCustomRoute(token: String!, id: ID!): Boolean! } - `, mountainTypeDefs], - resolvers: [{ Query: { - routeStops: async (_:unknown, {token,geojson}:ApiArgs) => {await requireUser(token);return routeStops(geojson)}, - elevationProfile: async (_:unknown, {token,geojson}:ApiArgs) => {await requireUser(token);return elevationProfile(geojson)}, - suggestRoundTrips: async (_:unknown, {token,start,desiredLengthMeters,routeId}:ApiArgs) => {await requireUser(token);return suggestRoundTrips(start,desiredLengthMeters,routeId)}, - builderNetwork: async (_:unknown, { token, routeId }:ApiArgs) => { await requireUser(token); return (await builderNetwork(routeId)).geojson }, - previewCustomRoute: async (_:unknown, { token, points, routeId }:ApiArgs) => { await requireUser(token); return planCustomRoute(points,routeId) }, - myCustomRoutes: async (_:unknown, { token }:ApiArgs) => { - const user=await requireUser(token) - const { rows }=await pool.query('SELECT id,name,description,geojson,length_meters AS "lengthMeters",waypoints,updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops FROM norwegian_explorer_app.custom_routes WHERE user_id=$1 ORDER BY updated_at DESC',[user.id]) - return rows - }, - osmNearby: async (_:unknown, { west, south, east, north }:ApiArgs) => { - if (![west,south,east,north].every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north || east-west > 0.5 || north-south > 0.5) throw new GraphQLError('Velg et mindre kartområde for OSM-data.') - const { rows } = await pool.query(` + `, + mountainTypeDefs, + ], + resolvers: [ + { + Query: { + routeStops: async (_: unknown, { token, geojson }: ApiArgs) => { + await requireUser(token) + return routeStops(geojson) + }, + elevationProfile: async (_: unknown, { token, geojson }: ApiArgs) => { + await requireUser(token) + return elevationProfile(geojson) + }, + suggestRoundTrips: async ( + _: unknown, + { token, start, desiredLengthMeters, routeId }: ApiArgs, + ) => { + await requireUser(token) + return suggestRoundTrips(start, desiredLengthMeters, routeId) + }, + builderNetwork: async (_: unknown, { token, routeId }: ApiArgs) => { + await requireUser(token) + return (await builderNetwork(routeId)).geojson + }, + previewCustomRoute: async (_: unknown, { token, points, routeId }: ApiArgs) => { + await requireUser(token) + return planCustomRoute(points, routeId) + }, + myCustomRoutes: async (_: unknown, { token }: ApiArgs) => { + const user = await requireUser(token) + const { rows } = await pool.query( + 'SELECT id,name,description,geojson,length_meters AS "lengthMeters",waypoints,updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops FROM norwegian_explorer_app.custom_routes WHERE user_id=$1 ORDER BY updated_at DESC', + [user.id], + ) + return rows + }, + osmNearby: async (_: unknown, { west, south, east, north }: ApiArgs) => { + if ( + ![west, south, east, north].every(Number.isFinite) || + west < -180 || + east > 180 || + south < -90 || + north > 90 || + west >= east || + south >= north || + east - west > 0.5 || + north - south > 0.5 + ) + throw new GraphQLError('Velg et mindre kartområde for OSM-data.') + const { rows } = await pool.query( + ` SELECT osm_type,osm_id::text,name,category, coalesce(tags->>'amenity',tags->>'tourism',tags->>'natural',tags->>'highway') AS kind, ST_AsGeoJSON(CASE WHEN category='place' THEN ST_PointOnSurface(geom) @@ -322,40 +477,94 @@ const schema = createSchema({ AND coalesce(tags->>'access','') NOT IN ('no','private') AND coalesce(tags->>'foot','') NOT IN ('no','private') AND (category='place' OR GeometryType(geom) IN ('LINESTRING','MULTILINESTRING')) - ORDER BY category,osm_type,osm_id LIMIT 1001`, [west,south,east,north]) - return { truncated: rows.length>1000, geojson: JSON.stringify({ type:'FeatureCollection', features: rows.slice(0,1000).map(r => ({ type:'Feature', geometry:r.geometry, properties:{ id:r.osm_type+'/'+r.osm_id, name:r.name, category:r.category, kind:r.kind } })) }) } - }, - areas: async (_:unknown, { county }:ApiArgs) => { - await routeCatalogReady - const [countyResult, municipalityResult] = await Promise.all([ - pool.query(`SELECT county_number AS code,min(county_name) AS name,count(DISTINCT route_id)::int AS "routeCount" + ORDER BY category,osm_type,osm_id LIMIT 1001`, + [west, south, east, north], + ) + return { + truncated: rows.length > 1000, + geojson: JSON.stringify({ + type: 'FeatureCollection', + features: rows.slice(0, 1000).map((r) => ({ + type: 'Feature', + geometry: r.geometry, + properties: { + id: r.osm_type + '/' + r.osm_id, + name: r.name, + category: r.category, + kind: r.kind, + }, + })), + }), + } + }, + areas: async (_: unknown, { county }: ApiArgs) => { + await routeCatalogReady + const [countyResult, municipalityResult] = await Promise.all([ + pool.query(`SELECT county_number AS code,min(county_name) AS name,count(DISTINCT route_id)::int AS "routeCount" FROM norwegian_explorer_app.route_areas GROUP BY county_number ORDER BY name`), - pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name,count(DISTINCT route_id)::int AS "routeCount" + pool.query( + `SELECT municipality_number AS code,min(municipality_name) AS name,count(DISTINCT route_id)::int AS "routeCount" FROM norwegian_explorer_app.route_areas WHERE ($1::text IS NULL OR county_number=$1) - GROUP BY municipality_number ORDER BY name`, [county || null]), - ]) - return { counties: countyResult.rows, municipalities: municipalityResult.rows } - }, - routes: async (_:unknown, args:ApiArgs) => { - const { search, minLength, maxLength, county, municipality, west, south, east, north, offset, limit } = args - const { difficulty, routeType, facility, sort } = args as unknown as RouteListArgs - if (![difficulty, routeType, facility].every(validFilterList) || !Object.hasOwn(ROUTE_SORT_ORDER, sort)) throw new GraphQLError('Ugyldig filter eller sortering.') - await routeCatalogReady - const bounds = [west,south,east,north] - const hasBounds = bounds.every(Number.isFinite) - if (search == null || search.length > 120 || !Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 30 || - (minLength != null && (!Number.isFinite(minLength) || minLength < 0)) || (maxLength != null && (!Number.isFinite(maxLength) || maxLength < 0)) || - (minLength != null && maxLength != null && minLength > maxLength) || - (bounds.some(value => value != null) && !hasBounds) || (hasBounds && (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north))) { - throw new GraphQLError('Ugyldig søk eller sidestørrelse.') - } - // Turer nær alle valgte steder: snittet av id-listene for hver stedstype (listene er mellomlagret i routeStops.ts) - let facilityIds: string[] | null = null - for (const item of new Set(facility ?? [])) { - const ids = new Set(await nearbyFacilityRouteIds(item) ?? []) - facilityIds = facilityIds ? facilityIds.filter(id => ids.has(id)) : [...ids] - } - const { rows } = await pool.query(` + GROUP BY municipality_number ORDER BY name`, + [county || null], + ), + ]) + return { counties: countyResult.rows, municipalities: municipalityResult.rows } + }, + routes: async (_: unknown, args: ApiArgs) => { + const { + search, + minLength, + maxLength, + county, + municipality, + west, + south, + east, + north, + offset, + limit, + } = args + const { difficulty, routeType, facility, sort } = args as unknown as RouteListArgs + if ( + ![difficulty, routeType, facility].every(validFilterList) || + !Object.hasOwn(ROUTE_SORT_ORDER, sort) + ) + throw new GraphQLError('Ugyldig filter eller sortering.') + await routeCatalogReady + const bounds = [west, south, east, north] + const hasBounds = bounds.every(Number.isFinite) + if ( + search == null || + search.length > 120 || + !Number.isInteger(offset) || + offset < 0 || + offset > 100000 || + !Number.isInteger(limit) || + limit < 1 || + limit > 30 || + (minLength != null && (!Number.isFinite(minLength) || minLength < 0)) || + (maxLength != null && (!Number.isFinite(maxLength) || maxLength < 0)) || + (minLength != null && maxLength != null && minLength > maxLength) || + (bounds.some((value) => value != null) && !hasBounds) || + (hasBounds && + (west < -180 || + east > 180 || + south < -90 || + north > 90 || + west >= east || + south >= north)) + ) { + throw new GraphQLError('Ugyldig søk eller sidestørrelse.') + } + // Turer nær alle valgte steder: snittet av id-listene for hver stedstype (listene er mellomlagret i routeStops.ts) + let facilityIds: string[] | null = null + for (const item of new Set(facility ?? [])) { + const ids = new Set((await nearbyFacilityRouteIds(item)) ?? []) + facilityIds = facilityIds ? facilityIds.filter((id) => ids.has(id)) : [...ids] + } + const { rows } = await pool.query( + ` WITH favorite_counts AS ( SELECT route_id, count(*)::int AS n FROM norwegian_explorer_app.favorites WHERE route_id IS NOT NULL GROUP BY route_id ), base AS ( @@ -402,15 +611,34 @@ const schema = createSchema({ ) SELECT (SELECT count(*)::int FROM grouped) AS total, coalesce((SELECT json_agg(page ORDER BY position) FROM page),'[]'::json) AS items - `, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty?.length ? difficulty : null, routeType?.length ? routeType : null, county || null, municipality || null, limit, offset,facilityIds]) - return rows[0] - }, - popularRoutes: async (_:unknown, { limit }:ApiArgs) => { - if (!Number.isInteger(limit) || limit < 1 || limit > 30) throw new GraphQLError('Ugyldig antall.') - await routeCatalogReady - // Én rad per rutenavn (den mest lagrede varianten). Har få turer favoritter ennå, fylles lista opp - // med graderte turer som har beskrivelse, deretter turer som er registrert flere ganger i Kartverkets data. - const { rows } = await pool.query(` + `, + [ + search.trim(), + minLength ?? null, + maxLength ?? null, + hasBounds ? west : null, + hasBounds ? south : null, + hasBounds ? east : null, + hasBounds ? north : null, + difficulty?.length ? difficulty : null, + routeType?.length ? routeType : null, + county || null, + municipality || null, + limit, + offset, + facilityIds, + ], + ) + return rows[0] + }, + popularRoutes: async (_: unknown, { limit }: ApiArgs) => { + if (!Number.isInteger(limit) || limit < 1 || limit > 30) + throw new GraphQLError('Ugyldig antall.') + await routeCatalogReady + // Én rad per rutenavn (den mest lagrede varianten). Har få turer favoritter ennå, fylles lista opp + // med graderte turer som har beskrivelse, deretter turer som er registrert flere ganger i Kartverkets data. + const { rows } = await pool.query( + ` WITH favorite_counts AS ( SELECT route_id, count(*)::int AS n FROM norwegian_explorer_app.favorites WHERE route_id IS NOT NULL GROUP BY route_id @@ -432,32 +660,40 @@ const schema = createSchema({ FROM candidates ORDER BY favorite_count DESC, (difficulty IS NOT NULL AND description IS NOT NULL) DESC, duplicate_count DESC, name - LIMIT $1`, [limit]) - return rows - }, - searchMunicipalities: async (_:unknown, { query, limit }:ApiArgs) => { - const search = query.trim() - if (!search || search.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 20) throw new GraphQLError('Ugyldig søk.') - await routeCatalogReady - // Navn som starter med søketeksten kommer først, deretter kommunene med flest turer - const { rows } = await pool.query(` + LIMIT $1`, + [limit], + ) + return rows + }, + searchMunicipalities: async (_: unknown, { query, limit }: ApiArgs) => { + const search = query.trim() + if (!search || search.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 20) + throw new GraphQLError('Ugyldig søk.') + await routeCatalogReady + // Navn som starter med søketeksten kommer først, deretter kommunene med flest turer + const { rows } = await pool.query( + ` SELECT municipality_number AS code, min(municipality_name) AS name, count(DISTINCT route_id)::int AS "routeCount" FROM norwegian_explorer_app.route_areas WHERE strpos(lower(municipality_name), lower($1)) > 0 GROUP BY municipality_number ORDER BY bool_or(lower(municipality_name) LIKE lower($1) || '%') DESC, "routeCount" DESC, name - LIMIT $2`, [search, limit]) - return rows - }, - exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), - route: (_, { name, number }) => { - if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') - return loadRoute(name, number) - }, - routeVariants: async (_:unknown, { name }:ApiArgs) => { - if (!name.trim() || name.length > 500) throw new GraphQLError('Ugyldig rutenavn.') - await routeCatalogReady - const { rows } = await pool.query(` + LIMIT $2`, + [search, limit], + ) + return rows + }, + exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), + route: (_, { name, number }) => { + if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) + throw new GraphQLError('Ugyldig rute.') + return loadRoute(name, number) + }, + routeVariants: async (_: unknown, { name }: ApiArgs) => { + if (!name.trim() || name.length > 500) throw new GraphQLError('Ugyldig rutenavn.') + await routeCatalogReady + const { rows } = await pool.query( + ` WITH variants AS ( SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type, d.duplicate_count AS "duplicateCount" @@ -471,244 +707,446 @@ const schema = createSchema({ ) SELECT id,name,number,length_meters AS "lengthMeters",difficulty, route_type AS "routeType","duplicateCount",count(*) OVER ()::int AS "variantCount" - FROM variants ORDER BY CASE WHEN number='SAMLET_RUNDE' THEN 0 ELSE 1 END,length_meters,number`, [name.trim()]) - return rows - }, - me: async (_:unknown, { token }:ApiArgs) => { - const user = await userFromToken(token) - return user ? profileFor(user) : null - }, - }, Mutation: { - register: async (_:unknown, { username, password, email }:ApiArgs) => { - await appSchemaReady - checkAuthAttempt(username) - const cleanUsername = validateCredentials(username, password) - const cleanEmail = validateEmail(email) - const salt = randomBytes(16) - const hash = await passwordHash(password, salt) - const client = await pool.connect() - try { - await client.query('BEGIN') - const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt,email) - VALUES ($1,$2,$3,$4) RETURNING id,username,email`, [cleanUsername, hash, salt, cleanEmail]) - const token = await createSession(client, rows[0].id) - await client.query('COMMIT') - return { token, user: { username: rows[0].username, email: cleanEmail, homeMunicipality:null, favorites: [], trips: [], goals: [], - stats: { completedCount: 0, plannedCount: 0, totalLengthMeters: 0, countyCount: 0 } } } - } catch (error) { - await client.query('ROLLBACK') - if (error && typeof error==='object' && 'code' in error && error.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.') - throw error - } finally { client.release() } - }, - requestPasswordReset: async (_:unknown, { email }:ApiArgs) => { - await appSchemaReady - const cleanEmail = validateEmail(email) - checkAuthAttempt(`reset:${cleanEmail}`) - if (!resetDeliveryAvailable()) throw new GraphQLError('Passordtilbakestilling er ikke tilgjengelig ennå. Kontakt prosjektgruppen.') - const {rows} = await pool.query<{id:string}>('SELECT id FROM norwegian_explorer_app.users WHERE lower(email)=$1', [cleanEmail]) - for (const user of rows) { - const resetToken = randomBytes(32).toString('base64url') - await pool.query("INSERT INTO norwegian_explorer_app.password_resets(token_hash,user_id,expires_at) VALUES ($1,$2,now()+interval '30 minutes')", [tokenHash(resetToken), user.id]) - try { await sendPasswordReset(cleanEmail, resetToken) } - catch { await pool.query('DELETE FROM norwegian_explorer_app.password_resets WHERE token_hash=$1',[tokenHash(resetToken)]); throw new GraphQLError('Kunne ikke sende e-post akkurat nå. Prøv igjen senere.') } - } - await pool.query('DELETE FROM norwegian_explorer_app.password_resets WHERE expires_at<=now()') - return true - }, - resetPassword: async (_:unknown, {token, newPassword}:ApiArgs) => { - await appSchemaReady - checkAuthAttempt('password-reset') - if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.') - if (newPassword.length<10 || newPassword.length>200) throw new GraphQLError('Passordet må være mellom 10 og 200 tegn.') - const client = await pool.connect() - try { - await client.query('BEGIN') - const {rows} = await client.query<{user_id:string}>('SELECT user_id FROM norwegian_explorer_app.password_resets WHERE token_hash=$1 AND expires_at>now()', [tokenHash(token)]) - if (!rows.length) throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.') - const userId = rows[0].user_id - await client.query('SELECT id FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE',[userId]) - const valid = await client.query('SELECT 1 FROM norwegian_explorer_app.password_resets WHERE token_hash=$1 AND expires_at>now()', [tokenHash(token)]) - if (!valid.rowCount) throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.') - const salt = randomBytes(16) - await client.query('UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1',[userId,await passwordHash(newPassword,salt),salt]) - await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1',[userId]) - await client.query('DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1',[userId]) - await client.query('COMMIT') - return true - } catch(error) { await client.query('ROLLBACK'); throw error } - finally { client.release() } - }, - login: async (_:unknown, { username, password }:ApiArgs) => { - checkAuthAttempt(username) - if(username.length>100 || password.length>200)throw new GraphQLError('Feil brukernavn eller passord.') - await appSchemaReady - const client=await pool.connect() - let user:AuthUser,token:string - try{ - await client.query('BEGIN') - const {rows}=await client.query(`SELECT id,username,email,phone,avatar,${await homeColumn()},password_hash,password_salt FROM norwegian_explorer_app.users WHERE lower(username)=lower($1) FOR UPDATE`,[username.trim()]) - user=rows[0] - const candidate=await passwordHash(password,user?.password_salt || Buffer.alloc(16)) - if(!user || candidate.length!==user.password_hash.length || !timingSafeEqual(candidate,user.password_hash))throw new GraphQLError('Feil brukernavn eller passord.') - token=await createSession(client,user.id) - await client.query('COMMIT') - }catch(error){await client.query('ROLLBACK');throw error}finally{client.release()} - return {token,user:await profileFor(user)} - }, - logout: async (_:unknown, { token }:ApiArgs) => { - await appSchemaReady - await pool.query('DELETE FROM norwegian_explorer_app.sessions WHERE token_hash=$1', [tokenHash(token)]) - return true - }, - toggleFavorite: async (_:unknown, { token, routeId }:ApiArgs) => { - if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') - const user = await requireUser(token) - await routeCatalogReady - const routeResult = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 - UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) - if (!routeResult.rows[0]) throw new GraphQLError('Ruten finnes ikke.') - const client=await pool.connect() - try{ - await client.query('BEGIN') - await client.query('SELECT id FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE',[user.id]) - const removed=await client.query('DELETE FROM norwegian_explorer_app.favorites WHERE user_id=$1 AND route_id=$2 RETURNING 1',[user.id,routeId]) - if(!removed.rowCount)await client.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES($1,$2,$3,$4)',[user.id,routeId,routeResult.rows[0].name,routeResult.rows[0].number]) - await client.query('COMMIT') - }catch(error){await client.query('ROLLBACK');throw error}finally{client.release()} - return profileFor(user) - }, - saveCustomRoute: async (_:unknown, { token, id, name, description, points, routeId, roundTrip, stopIds }:ApiArgs) => { - const user=await requireUser(token) - if (!name.trim() || name.trim().length>100 || description.length>2000) throw new GraphQLError('Gi ruten et navn på maks 100 tegn og en beskrivelse på maks 2000 tegn.') - if (id && !/^[a-f0-9]{32}$/.test(id)) throw new GraphQLError('Ugyldig rute.') - const preview=roundTrip ? (await suggestRoundTrips(roundTrip.start,roundTrip.desiredLengthMeters,routeId)).find(item=>item.id===roundTrip.proposalId) : await planCustomRoute(points,routeId) - if(!preview)throw new GraphQLError('Rundturforslaget er ikke lenger tilgjengelig. Beregn nye forslag.') - const stops=(await routeStops(preview.geojson,stopIds || [])).stops - const customId=id || randomBytes(16).toString('hex') - const query=id ? `UPDATE norwegian_explorer_app.custom_routes SET name=$3,description=$4,waypoints=$5,geojson=$6,length_meters=$7,source_route_id=$8,source_name=$9,smart_plan=$10,stops=$11,updated_at=now() WHERE id=$1 AND user_id=$2 RETURNING id,name,description,waypoints,geojson,length_meters AS "lengthMeters",updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops` : - `INSERT INTO norwegian_explorer_app.custom_routes(id,user_id,name,description,waypoints,geojson,length_meters,source_route_id,source_name,smart_plan,stops) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id,name,description,waypoints,geojson,length_meters AS "lengthMeters",updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops` - const { rows }=await pool.query(query,[customId,user.id,name.trim(),description.trim(),JSON.stringify(preview.waypoints),preview.geojson,preview.lengthMeters,preview.sourceRouteId,preview.sourceName,roundTrip ? JSON.stringify(roundTrip) : null,JSON.stringify(stops)]) - if (!rows[0]) throw new GraphQLError('Ruten finnes ikke på profilen din.') - return rows[0] - }, - deleteCustomRoute: async (_:unknown, { token, id }:ApiArgs) => { - const user=await requireUser(token) - const result=await pool.query('DELETE FROM norwegian_explorer_app.custom_routes WHERE id=$1 AND user_id=$2',[id,user.id]) - return (result.rowCount||0)>0 - }, - changePassword: async (_:unknown, { token, currentPassword, newPassword }:ApiArgs) => { - const user = await requireUser(token) - checkAuthAttempt(user.username) - if (currentPassword.length > 200) throw new GraphQLError('Gjeldende passord er feil.') - validateCredentials(user.username, newPassword) - if (currentPassword === newPassword) throw new GraphQLError('Velg et annet passord enn det du bruker nå.') - const client = await pool.connect() - try { - await client.query('BEGIN') - const { rows } = await client.query('SELECT password_hash,password_salt FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE', [user.id]) - const stored = rows[0] - if (!stored) throw new GraphQLError('Du må logge inn på nytt.') - const candidate = await passwordHash(currentPassword, stored.password_salt) - if (!timingSafeEqual(candidate, stored.password_hash)) throw new GraphQLError('Gjeldende passord er feil.') - const salt = randomBytes(16) - const hash = await passwordHash(newPassword, salt) - await client.query('UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1', [user.id,hash,salt]) - await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1', [user.id]) - await client.query('DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1', [user.id]) - await client.query('COMMIT') - return true - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { client.release() } - }, - setMonthlyGoal: async (_:unknown, { token, month, target }:ApiArgs) => { - const user = await requireUser(token) - if (!/^[0-9]{4}-(0[1-9]|1[0-2])$/.test(month)) throw new GraphQLError('Velg en gyldig måned.') - if (target == null) { - await pool.query('DELETE FROM norwegian_explorer_app.user_goals WHERE user_id=$1 AND month=$2', [user.id,month]) - } else { - if (!Number.isInteger(target) || target < 1 || target > 1000) throw new GraphQLError('Målet må være mellom 1 og 1000 turer.') - await pool.query(`INSERT INTO norwegian_explorer_app.user_goals(user_id,month,target) VALUES ($1,$2,$3) - ON CONFLICT (user_id,month) DO UPDATE SET target=excluded.target`, [user.id,month,target]) - } - return profileFor(user) - }, - saveTripDiary: async (_:unknown, { token, routeId, completedOn, diaryNote, rating }:ApiArgs) => { - const user = await requireUser(token) - if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') - const date = completedOn || null - if (date && (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0,10) !== date)) throw new GraphQLError('Skriv inn en gyldig dato.') - if (diaryNote && diaryNote.length > 2000) throw new GraphQLError('Notatet kan være maks 2000 tegn.') - if (rating != null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) throw new GraphQLError('Vurderingen må være mellom 1 og 5.') - const result = await pool.query(`UPDATE norwegian_explorer_app.user_routes + FROM variants ORDER BY CASE WHEN number='SAMLET_RUNDE' THEN 0 ELSE 1 END,length_meters,number`, + [name.trim()], + ) + return rows + }, + me: async (_: unknown, { token }: ApiArgs) => { + const user = await userFromToken(token) + return user ? profileFor(user) : null + }, + }, + Mutation: { + register: async (_: unknown, { username, password, email }: ApiArgs) => { + await appSchemaReady + checkAuthAttempt(username) + const cleanUsername = validateCredentials(username, password) + const cleanEmail = validateEmail(email) + const salt = randomBytes(16) + const hash = await passwordHash(password, salt) + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows } = await client.query( + `INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt,email) + VALUES ($1,$2,$3,$4) RETURNING id,username,email`, + [cleanUsername, hash, salt, cleanEmail], + ) + const token = await createSession(client, rows[0].id) + await client.query('COMMIT') + return { + token, + user: { + username: rows[0].username, + email: cleanEmail, + homeMunicipality: null, + favorites: [], + trips: [], + goals: [], + stats: { completedCount: 0, plannedCount: 0, totalLengthMeters: 0, countyCount: 0 }, + }, + } + } catch (error) { + await client.query('ROLLBACK') + if (error && typeof error === 'object' && 'code' in error && error.code === '23505') + throw new GraphQLError('Brukernavnet er allerede i bruk.') + throw error + } finally { + client.release() + } + }, + requestPasswordReset: async (_: unknown, { email }: ApiArgs) => { + await appSchemaReady + const cleanEmail = validateEmail(email) + checkAuthAttempt(`reset:${cleanEmail}`) + if (!resetDeliveryAvailable()) + throw new GraphQLError( + 'Passordtilbakestilling er ikke tilgjengelig ennå. Kontakt prosjektgruppen.', + ) + const { rows } = await pool.query<{ id: string }>( + 'SELECT id FROM norwegian_explorer_app.users WHERE lower(email)=$1', + [cleanEmail], + ) + for (const user of rows) { + const resetToken = randomBytes(32).toString('base64url') + await pool.query( + "INSERT INTO norwegian_explorer_app.password_resets(token_hash,user_id,expires_at) VALUES ($1,$2,now()+interval '30 minutes')", + [tokenHash(resetToken), user.id], + ) + try { + await sendPasswordReset(cleanEmail, resetToken) + } catch { + await pool.query( + 'DELETE FROM norwegian_explorer_app.password_resets WHERE token_hash=$1', + [tokenHash(resetToken)], + ) + throw new GraphQLError('Kunne ikke sende e-post akkurat nå. Prøv igjen senere.') + } + } + await pool.query( + 'DELETE FROM norwegian_explorer_app.password_resets WHERE expires_at<=now()', + ) + return true + }, + resetPassword: async (_: unknown, { token, newPassword }: ApiArgs) => { + await appSchemaReady + checkAuthAttempt('password-reset') + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) + throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.') + if (newPassword.length < 10 || newPassword.length > 200) + throw new GraphQLError('Passordet må være mellom 10 og 200 tegn.') + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows } = await client.query<{ user_id: string }>( + 'SELECT user_id FROM norwegian_explorer_app.password_resets WHERE token_hash=$1 AND expires_at>now()', + [tokenHash(token)], + ) + if (!rows.length) + throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.') + const userId = rows[0].user_id + await client.query( + 'SELECT id FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE', + [userId], + ) + const valid = await client.query( + 'SELECT 1 FROM norwegian_explorer_app.password_resets WHERE token_hash=$1 AND expires_at>now()', + [tokenHash(token)], + ) + if (!valid.rowCount) + throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.') + const salt = randomBytes(16) + await client.query( + 'UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1', + [userId, await passwordHash(newPassword, salt), salt], + ) + await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1', [ + userId, + ]) + await client.query( + 'DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1', + [userId], + ) + await client.query('COMMIT') + return true + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, + login: async (_: unknown, { username, password }: ApiArgs) => { + checkAuthAttempt(username) + if (username.length > 100 || password.length > 200) + throw new GraphQLError('Feil brukernavn eller passord.') + await appSchemaReady + const client = await pool.connect() + let user: AuthUser, token: string + try { + await client.query('BEGIN') + const { rows } = await client.query( + `SELECT id,username,email,phone,avatar,${await homeColumn()},password_hash,password_salt FROM norwegian_explorer_app.users WHERE lower(username)=lower($1) FOR UPDATE`, + [username.trim()], + ) + user = rows[0] + const candidate = await passwordHash(password, user?.password_salt || Buffer.alloc(16)) + if ( + !user || + candidate.length !== user.password_hash.length || + !timingSafeEqual(candidate, user.password_hash) + ) + throw new GraphQLError('Feil brukernavn eller passord.') + token = await createSession(client, user.id) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + return { token, user: await profileFor(user) } + }, + logout: async (_: unknown, { token }: ApiArgs) => { + await appSchemaReady + await pool.query('DELETE FROM norwegian_explorer_app.sessions WHERE token_hash=$1', [ + tokenHash(token), + ]) + return true + }, + toggleFavorite: async (_: unknown, { token, routeId }: ApiArgs) => { + if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + const user = await requireUser(token) + await routeCatalogReady + const routeResult = await pool.query( + `SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, + [routeId], + ) + if (!routeResult.rows[0]) throw new GraphQLError('Ruten finnes ikke.') + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query( + 'SELECT id FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE', + [user.id], + ) + const removed = await client.query( + 'DELETE FROM norwegian_explorer_app.favorites WHERE user_id=$1 AND route_id=$2 RETURNING 1', + [user.id, routeId], + ) + if (!removed.rowCount) + await client.query( + 'INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES($1,$2,$3,$4)', + [user.id, routeId, routeResult.rows[0].name, routeResult.rows[0].number], + ) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + return profileFor(user) + }, + saveCustomRoute: async ( + _: unknown, + { token, id, name, description, points, routeId, roundTrip, stopIds }: ApiArgs, + ) => { + const user = await requireUser(token) + if (!name.trim() || name.trim().length > 100 || description.length > 2000) + throw new GraphQLError( + 'Gi ruten et navn på maks 100 tegn og en beskrivelse på maks 2000 tegn.', + ) + if (id && !/^[a-f0-9]{32}$/.test(id)) throw new GraphQLError('Ugyldig rute.') + const preview = roundTrip + ? ( + await suggestRoundTrips(roundTrip.start, roundTrip.desiredLengthMeters, routeId) + ).find((item) => item.id === roundTrip.proposalId) + : await planCustomRoute(points, routeId) + if (!preview) + throw new GraphQLError( + 'Rundturforslaget er ikke lenger tilgjengelig. Beregn nye forslag.', + ) + const stops = (await routeStops(preview.geojson, stopIds || [])).stops + const customId = id || randomBytes(16).toString('hex') + const query = id + ? `UPDATE norwegian_explorer_app.custom_routes SET name=$3,description=$4,waypoints=$5,geojson=$6,length_meters=$7,source_route_id=$8,source_name=$9,smart_plan=$10,stops=$11,updated_at=now() WHERE id=$1 AND user_id=$2 RETURNING id,name,description,waypoints,geojson,length_meters AS "lengthMeters",updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops` + : `INSERT INTO norwegian_explorer_app.custom_routes(id,user_id,name,description,waypoints,geojson,length_meters,source_route_id,source_name,smart_plan,stops) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id,name,description,waypoints,geojson,length_meters AS "lengthMeters",updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops` + const { rows } = await pool.query(query, [ + customId, + user.id, + name.trim(), + description.trim(), + JSON.stringify(preview.waypoints), + preview.geojson, + preview.lengthMeters, + preview.sourceRouteId, + preview.sourceName, + roundTrip ? JSON.stringify(roundTrip) : null, + JSON.stringify(stops), + ]) + if (!rows[0]) throw new GraphQLError('Ruten finnes ikke på profilen din.') + return rows[0] + }, + deleteCustomRoute: async (_: unknown, { token, id }: ApiArgs) => { + const user = await requireUser(token) + const result = await pool.query( + 'DELETE FROM norwegian_explorer_app.custom_routes WHERE id=$1 AND user_id=$2', + [id, user.id], + ) + return (result.rowCount || 0) > 0 + }, + changePassword: async (_: unknown, { token, currentPassword, newPassword }: ApiArgs) => { + const user = await requireUser(token) + checkAuthAttempt(user.username) + if (currentPassword.length > 200) throw new GraphQLError('Gjeldende passord er feil.') + validateCredentials(user.username, newPassword) + if (currentPassword === newPassword) + throw new GraphQLError('Velg et annet passord enn det du bruker nå.') + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows } = await client.query( + 'SELECT password_hash,password_salt FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE', + [user.id], + ) + const stored = rows[0] + if (!stored) throw new GraphQLError('Du må logge inn på nytt.') + const candidate = await passwordHash(currentPassword, stored.password_salt) + if (!timingSafeEqual(candidate, stored.password_hash)) + throw new GraphQLError('Gjeldende passord er feil.') + const salt = randomBytes(16) + const hash = await passwordHash(newPassword, salt) + await client.query( + 'UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1', + [user.id, hash, salt], + ) + await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1', [ + user.id, + ]) + await client.query( + 'DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1', + [user.id], + ) + await client.query('COMMIT') + return true + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, + setMonthlyGoal: async (_: unknown, { token, month, target }: ApiArgs) => { + const user = await requireUser(token) + if (!/^[0-9]{4}-(0[1-9]|1[0-2])$/.test(month)) + throw new GraphQLError('Velg en gyldig måned.') + if (target == null) { + await pool.query( + 'DELETE FROM norwegian_explorer_app.user_goals WHERE user_id=$1 AND month=$2', + [user.id, month], + ) + } else { + if (!Number.isInteger(target) || target < 1 || target > 1000) + throw new GraphQLError('Målet må være mellom 1 og 1000 turer.') + await pool.query( + `INSERT INTO norwegian_explorer_app.user_goals(user_id,month,target) VALUES ($1,$2,$3) + ON CONFLICT (user_id,month) DO UPDATE SET target=excluded.target`, + [user.id, month, target], + ) + } + return profileFor(user) + }, + saveTripDiary: async ( + _: unknown, + { token, routeId, completedOn, diaryNote, rating }: ApiArgs, + ) => { + const user = await requireUser(token) + if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + const date = completedOn || null + if ( + date && + (!/^\d{4}-\d{2}-\d{2}$/.test(date) || + !Number.isFinite(Date.parse(date)) || + new Date(date).toISOString().slice(0, 10) !== date) + ) + throw new GraphQLError('Skriv inn en gyldig dato.') + if (diaryNote && diaryNote.length > 2000) + throw new GraphQLError('Notatet kan være maks 2000 tegn.') + if (rating != null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) + throw new GraphQLError('Vurderingen må være mellom 1 og 5.') + const result = await pool.query( + `UPDATE norwegian_explorer_app.user_routes SET completed_on=$3,diary_note=$4,rating=$5,updated_at=now() WHERE user_id=$1 AND route_id=$2 AND status='COMPLETED' RETURNING route_id`, - [user.id,routeId,date,diaryNote?.trim() || null,rating ?? null]) - if (!result.rowCount) throw new GraphQLError('Marker turen som gjennomført før du skriver i dagboken.') - return profileFor(user) - }, - setTripStatus: async (_:unknown, { token, routeId, status }:ApiArgs) => { - const user = await requireUser(token) - if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') - if (status == null) { - await pool.query('DELETE FROM norwegian_explorer_app.user_routes WHERE user_id=$1 AND route_id=$2', [user.id,routeId]) - } else { - await routeCatalogReady - const { rows } = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 - UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) - if (!rows[0]) throw new GraphQLError('Ruten finnes ikke.') - await pool.query(`INSERT INTO norwegian_explorer_app.user_routes(user_id,route_id,route_name,route_number,status) + [user.id, routeId, date, diaryNote?.trim() || null, rating ?? null], + ) + if (!result.rowCount) + throw new GraphQLError('Marker turen som gjennomført før du skriver i dagboken.') + return profileFor(user) + }, + setTripStatus: async (_: unknown, { token, routeId, status }: ApiArgs) => { + const user = await requireUser(token) + if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + if (status == null) { + await pool.query( + 'DELETE FROM norwegian_explorer_app.user_routes WHERE user_id=$1 AND route_id=$2', + [user.id, routeId], + ) + } else { + await routeCatalogReady + const { rows } = await pool.query( + `SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, + [routeId], + ) + if (!rows[0]) throw new GraphQLError('Ruten finnes ikke.') + await pool.query( + `INSERT INTO norwegian_explorer_app.user_routes(user_id,route_id,route_name,route_number,status) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (user_id,route_id) DO UPDATE SET status=excluded.status,updated_at=now()`, - [user.id,routeId,rows[0].name,rows[0].number,status]) - } - return profileFor(user) - }, - setHomeMunicipality: async (_:unknown, { token, code }:ApiArgs) => { - const user = await requireUser(token) - const cleanCode = code?.trim() || null - // Godta bare kommuner som faktisk finnes i rutedataene - if (cleanCode && !(await municipalityByCode(cleanCode))) throw new GraphQLError('Ukjent kommune.') - if (!(await hasHomeMunicipality)) throw new GraphQLError('Stedsvalg er ikke slått på i databasen ennå.') - const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users SET home_municipality=$2 - WHERE id=$1 RETURNING id,username,email,phone,avatar,home_municipality`, [user.id, cleanCode]) - return profileFor(rows[0]) - }, - updateProfile: async (_:unknown, { token, email, phone, avatar }:ApiArgs) => { - const user = await requireUser(token) - const cleanEmail = validateEmail(email) - const cleanPhone = phone?.trim() || null - const cleanAvatar = avatar?.trim() || null - if(cleanEmail && cleanEmail.length>254)throw new GraphQLError('E-postadressen er for lang.') - if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.') - if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) throw new GraphQLError('Skriv inn et gyldig telefonnummer.') - if (cleanAvatar && (!/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/.test(cleanAvatar) || cleanAvatar.length > 600000)) throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.') - const client = await pool.connect() - let updated: AppUser - try { - await client.query('BEGIN') - const {rows} = await client.query('SELECT id, email FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE',[user.id]) - if (rows[0].email?.toLowerCase() !== cleanEmail) await client.query('DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1',[user.id]) - const result = await client.query(`UPDATE norwegian_explorer_app.users + [user.id, routeId, rows[0].name, rows[0].number, status], + ) + } + return profileFor(user) + }, + setHomeMunicipality: async (_: unknown, { token, code }: ApiArgs) => { + const user = await requireUser(token) + const cleanCode = code?.trim() || null + // Godta bare kommuner som faktisk finnes i rutedataene + if (cleanCode && !(await municipalityByCode(cleanCode))) + throw new GraphQLError('Ukjent kommune.') + if (!(await hasHomeMunicipality)) + throw new GraphQLError('Stedsvalg er ikke slått på i databasen ennå.') + const { rows } = await pool.query( + `UPDATE norwegian_explorer_app.users SET home_municipality=$2 + WHERE id=$1 RETURNING id,username,email,phone,avatar,home_municipality`, + [user.id, cleanCode], + ) + return profileFor(rows[0]) + }, + updateProfile: async (_: unknown, { token, email, phone, avatar }: ApiArgs) => { + const user = await requireUser(token) + const cleanEmail = validateEmail(email) + const cleanPhone = phone?.trim() || null + const cleanAvatar = avatar?.trim() || null + if (cleanEmail && cleanEmail.length > 254) + throw new GraphQLError('E-postadressen er for lang.') + if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) + throw new GraphQLError('Skriv inn en gyldig e-postadresse.') + if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) + throw new GraphQLError('Skriv inn et gyldig telefonnummer.') + if ( + cleanAvatar && + (!/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/.test(cleanAvatar) || + cleanAvatar.length > 600000) + ) + throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.') + const client = await pool.connect() + let updated: AppUser + try { + await client.query('BEGIN') + const { rows } = await client.query( + 'SELECT id, email FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE', + [user.id], + ) + if (rows[0].email?.toLowerCase() !== cleanEmail) + await client.query( + 'DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1', + [user.id], + ) + const result = await client.query( + `UPDATE norwegian_explorer_app.users SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar,${await homeColumn()}`, - [user.id, cleanEmail, cleanPhone, cleanAvatar]) - updated = result.rows[0] - await client.query('COMMIT') - } catch(error) { await client.query('ROLLBACK'); throw error } - finally { client.release() } - return profileFor(updated) + [user.id, cleanEmail, cleanPhone, cleanAvatar], + ) + updated = result.rows[0] + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + return profileFor(updated) + }, + }, + Municipality: { + // Regnes bare ut når klienten ber om feltet + bbox: ({ code }: { code: string }) => municipalityBbox(code), + }, + MunicipalityHit: { + bbox: ({ code }: { code: string }) => municipalityBbox(code), + }, }, - }, Municipality: { - // Regnes bare ut når klienten ber om feltet - bbox: ({ code }:{ code:string }) => municipalityBbox(code), - }, MunicipalityHit: { - bbox: ({ code }:{ code:string }) => municipalityBbox(code), - } }, mountainResolvers], + mountainResolvers, + ], }) -async function loadRoute(name:string, number:string) { - await routeCatalogReady - const { rows } = await pool.query(` +async function loadRoute(name: string, number: string) { + await routeCatalogReady + const { rows } = await pool.query( + ` SELECT id,name,number,segment_count AS "segmentCount",length_meters AS "lengthMeters", connected,maintainer,difficulty,route_type AS "routeType",description, name_quality AS "nameQuality",d.duplicate_count AS "duplicateCount", @@ -730,13 +1168,26 @@ async function loadRoute(name:string, number:string) { JOIN norwegian_explorer_app.route_duplicate_map d2 ON d2.route_id=c2.id AND d2.canonical_id=c2.id WHERE lower(c2.name)=lower(l.name)) AS "variantCount", ST_AsGeoJSON(ST_Transform(l.geom,4326)) AS geojson - FROM norwegian_explorer_app.route_loops l WHERE l.name=$1 AND l.number=$2`, [name.trim(), number.trim()]) - return rows[0] || null + FROM norwegian_explorer_app.route_loops l WHERE l.name=$1 AND l.number=$2`, + [name.trim(), number.trim()], + ) + return rows[0] || null } -const yoga = createYoga({ schema, plugins:[requestSafety], batching:false, cors: false, graphiql: process.env.NODE_ENV !== 'production' }) +const yoga = createYoga({ + schema, + plugins: [requestSafety], + batching: false, + cors: false, + graphiql: process.env.NODE_ENV !== 'production', +}) const server = createServer(yoga) const port = Number(process.env.PORT || 4000) server.listen(port, '127.0.0.1', () => console.log(`GraphQL: http://127.0.0.1:${port}/graphql`)) for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => server.close(async () => { await pool.end(); process.exit(0) })) + process.on(signal, () => + server.close(async () => { + await pool.end() + process.exit(0) + }), + ) } diff --git a/server/mountainAreas.ts b/server/mountainAreas.ts index e2b9f64..e145e2b 100644 --- a/server/mountainAreas.ts +++ b/server/mountainAreas.ts @@ -64,10 +64,15 @@ const AREAS_SQL = ` ` // Databasen gir høyeste topp som to kolonner. GraphQL-typen samler dem i ett objekt. -function toMountainArea({ highestPeakName, highestPeakElevation, ...area }: MountainAreaRow): MountainArea { - const highestPeak = highestPeakName !== null && highestPeakElevation !== null - ? { name: highestPeakName, elevation: highestPeakElevation } - : null +function toMountainArea({ + highestPeakName, + highestPeakElevation, + ...area +}: MountainAreaRow): MountainArea { + const highestPeak = + highestPeakName !== null && highestPeakElevation !== null + ? { name: highestPeakName, elevation: highestPeakElevation } + : null return { ...area, highestPeak } } @@ -78,9 +83,13 @@ let cache: { at: number; areas: Promise } | null = null export function getMountainAreas(): Promise { if (!cache || Date.now() - cache.at > CACHE_MS) { - const areas = pool.query(AREAS_SQL).then(({ rows }) => rows.map(toMountainArea)) + const areas = pool + .query(AREAS_SQL) + .then(({ rows }) => rows.map(toMountainArea)) cache = { at: Date.now(), areas } - areas.catch(() => { cache = null }) // en feil skal ikke huskes, neste forespørsel prøver igjen + areas.catch(() => { + cache = null + }) // en feil skal ikke huskes, neste forespørsel prøver igjen } return cache.areas } diff --git a/server/passwordMail.ts b/server/passwordMail.ts index 9b164ca..ddf5530 100644 --- a/server/passwordMail.ts +++ b/server/passwordMail.ts @@ -3,7 +3,12 @@ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { randomUUID } from 'node:crypto' -export function resetDeliveryAvailable() { return Boolean(process.env.SENDMAIL_PATH || (process.env.PASSWORD_RESET_OUTBOX && process.env.NODE_ENV !== 'production')) } +export function resetDeliveryAvailable() { + return Boolean( + process.env.SENDMAIL_PATH || + (process.env.PASSWORD_RESET_OUTBOX && process.env.NODE_ENV !== 'production'), + ) +} export async function sendPasswordReset(email: string, token: string) { const base = new URL(process.env.APP_PUBLIC_URL || 'http://localhost:5173/') if (!['http:', 'https:'].includes(base.protocol)) throw new Error('Invalid APP_PUBLIC_URL') @@ -13,14 +18,21 @@ export async function sendPasswordReset(email: string, token: string) { const message = `From: ${from}\nTo: ${email}\nSubject: Tilbakestill passordet ditt\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\n\nÅpne lenken for å velge et nytt passord:\n${base.href}\n\nLenken gjelder i 30 minutter og kan brukes én gang. Hvis du ikke ba om dette, kan du se bort fra meldingen.\n` if (process.env.SENDMAIL_PATH) { await new Promise((resolve, reject) => { - const child = spawn(process.env.SENDMAIL_PATH!, ['-t', '-i'], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 15000 }) + const child = spawn(process.env.SENDMAIL_PATH!, ['-t', '-i'], { + stdio: ['pipe', 'ignore', 'ignore'], + timeout: 15000, + }) child.once('error', reject) child.stdin.on('error', reject) - child.once('close', code => code === 0 ? resolve() : reject(new Error('Mail delivery failed'))) + child.once('close', (code) => + code === 0 ? resolve() : reject(new Error('Mail delivery failed')), + ) child.stdin.end(message) }) } else if (process.env.PASSWORD_RESET_OUTBOX && process.env.NODE_ENV !== 'production') { await mkdir(process.env.PASSWORD_RESET_OUTBOX, { recursive: true, mode: 0o700 }) - await writeFile(join(process.env.PASSWORD_RESET_OUTBOX, `${randomUUID()}.eml`), message, {mode: 0o600}) + await writeFile(join(process.env.PASSWORD_RESET_OUTBOX, `${randomUUID()}.eml`), message, { + mode: 0o600, + }) } else throw new Error('Mail delivery is not configured') } diff --git a/server/pointsOfInterest.ts b/server/pointsOfInterest.ts index dd86b61..ec125ff 100644 --- a/server/pointsOfInterest.ts +++ b/server/pointsOfInterest.ts @@ -23,7 +23,8 @@ export async function getAreaPointsOfInterest(areaId: string): Promise(` + const { rows } = await pool.query( + ` SELECT p.id, p.kind, p.dnt_type AS "dntType", p.name, p.lon::float8 AS lon, p.lat::float8 AS lat, p.elevation, p.capacity, @@ -33,7 +34,9 @@ export async function getAreaPointsOfInterest(areaId: string): Promise(` + const { rows } = await pool.query( + ` SELECT a.id AS "areaId", p.id, p.kind, p.dnt_type AS "dntType", p.name, p.lon::float8 AS lon, p.lat::float8 AS lat, p.elevation, p.capacity, true AS inside @@ -59,6 +63,8 @@ export async function searchPointsOfInterest(query: string): Promise ({ areaId, point })) } diff --git a/server/requestSafety.ts b/server/requestSafety.ts index 71bc69b..8aa0c80 100644 --- a/server/requestSafety.ts +++ b/server/requestSafety.ts @@ -1,41 +1,100 @@ -import type {ValidationContext,DocumentNode,SelectionSetNode,FragmentDefinitionNode} from 'graphql' -import type {Plugin,GraphQLParams} from 'graphql-yoga' -import {GraphQLError,Kind} from 'graphql' -export function queryBudget(context:ValidationContext){ - return {Document(document:DocumentNode){ - const fragments=new Map(document.definitions.filter(d=>d.kind===Kind.FRAGMENT_DEFINITION).map(d=>[d.name.value,d])) - for(const operation of document.definitions.filter(d=>d.kind===Kind.OPERATION_DEFINITION)){ - let fields=0,roots=0 - function walk(set:SelectionSetNode|undefined,depth:number,path=new Set()){ - if(depth>12)throw new Error('Spørringen er for dypt nestet.') - for(const selection of set?.selections||[]){ - if(selection.kind===Kind.FIELD){if(++fields>300||(depth===1&&++roots>20))throw new Error('Spørringen inneholder for mange felt.');walk(selection.selectionSet,depth+1,path)} - else if(selection.kind===Kind.INLINE_FRAGMENT)walk(selection.selectionSet,depth,path) - else {const name=selection.name.value;if(path.has(name))throw new Error('Spørringen inneholder en fragmentsyklus.');const fragment=fragments.get(name);if(fragment)walk(fragment.selectionSet,depth,new Set([...path,name]))} +import type { + ValidationContext, + DocumentNode, + SelectionSetNode, + FragmentDefinitionNode, +} from 'graphql' +import type { Plugin, GraphQLParams } from 'graphql-yoga' +import { GraphQLError, Kind } from 'graphql' +export function queryBudget(context: ValidationContext) { + return { + Document(document: DocumentNode) { + const fragments = new Map( + document.definitions + .filter((d) => d.kind === Kind.FRAGMENT_DEFINITION) + .map((d) => [d.name.value, d]), + ) + for (const operation of document.definitions.filter( + (d) => d.kind === Kind.OPERATION_DEFINITION, + )) { + let fields = 0, + roots = 0 + function walk(set: SelectionSetNode | undefined, depth: number, path = new Set()) { + if (depth > 12) throw new Error('Spørringen er for dypt nestet.') + for (const selection of set?.selections || []) { + if (selection.kind === Kind.FIELD) { + if (++fields > 300 || (depth === 1 && ++roots > 20)) + throw new Error('Spørringen inneholder for mange felt.') + walk(selection.selectionSet, depth + 1, path) + } else if (selection.kind === Kind.INLINE_FRAGMENT) + walk(selection.selectionSet, depth, path) + else { + const name = selection.name.value + if (path.has(name)) throw new Error('Spørringen inneholder en fragmentsyklus.') + const fragment = fragments.get(name) + if (fragment) walk(fragment.selectionSet, depth, new Set([...path, name])) + } + } + } + try { + walk(operation.selectionSet, 1) + } catch (error) { + context.reportError( + new GraphQLError(error instanceof Error ? error.message : 'Ugyldig spørring.'), + ) } } - try{walk(operation.selectionSet,1)}catch(error){context.reportError(new GraphQLError(error instanceof Error?error.message:'Ugyldig spørring.'))} - } - }} + }, + } } -export async function boundedJson(request:Request):Promise{ - const reader=request.body?.getReader();const chunks:Buffer[]=[];let length=0 - if(!reader)throw new GraphQLError('Forespørselen mangler innhold.') - try{while(true){const {done,value}=await reader.read();if(done)break;length+=value.byteLength;if(length>6000000){await reader.cancel();throw new GraphQLError('Forespørselen er for stor.')}chunks.push(Buffer.from(value))}} - finally{reader.releaseLock()} - try{return JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw new GraphQLError('Ugyldig JSON i forespørselen.')} +export async function boundedJson(request: Request): Promise { + const reader = request.body?.getReader() + const chunks: Buffer[] = [] + let length = 0 + if (!reader) throw new GraphQLError('Forespørselen mangler innhold.') + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + length += value.byteLength + if (length > 6000000) { + await reader.cancel() + throw new GraphQLError('Forespørselen er for stor.') + } + chunks.push(Buffer.from(value)) + } + } finally { + reader.releaseLock() + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')) + } catch { + throw new GraphQLError('Ugyldig JSON i forespørselen.') + } } -export const requestSafety:Plugin={ - onRequestParse({request,setRequestParser,endResponse}){ - if(request.method==='POST'){ - if(request.headers.get('content-type')?.split(';')[0].trim().toLowerCase()!=='application/json'){endResponse(new Response('Bruk application/json.',{status:415}));return} +export const requestSafety: Plugin = { + onRequestParse({ request, setRequestParser, endResponse }) { + if (request.method === 'POST') { + if ( + request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== + 'application/json' + ) { + endResponse(new Response('Bruk application/json.', { status: 415 })) + return + } setRequestParser(boundedJson) } }, - onParams({request,params}){ - if(request.method!=='POST')throw new GraphQLError('Send GraphQL-spørringer med POST.') - if(!params.query||params.query.length>100000)throw new GraphQLError('Spørringen mangler eller er for stor.') + onParams({ request, params }) { + if (request.method !== 'POST') throw new GraphQLError('Send GraphQL-spørringer med POST.') + if (!params.query || params.query.length > 100000) + throw new GraphQLError('Spørringen mangler eller er for stor.') + }, + onValidate({ addValidationRule }) { + addValidationRule(queryBudget) + }, + onResponse({ response }) { + response.headers.set('Cache-Control', 'no-store') + response.headers.set('X-Content-Type-Options', 'nosniff') }, - onValidate({addValidationRule}){addValidationRule(queryBudget)}, - onResponse({response}){response.headers.set('Cache-Control','no-store');response.headers.set('X-Content-Type-Options','nosniff')} } diff --git a/server/routeStops.ts b/server/routeStops.ts index 99c595f..a7443e2 100644 --- a/server/routeStops.ts +++ b/server/routeStops.ts @@ -1,60 +1,112 @@ -import {GraphQLError} from 'graphql' -import {pool} from './db.ts' -import {sampleRoute} from './elevation.ts' +import { GraphQLError } from 'graphql' +import { pool } from './db.ts' +import { sampleRoute } from './elevation.ts' -const kinds=['alpine_hut','wilderness_hut','chalet','shelter','lean_to','viewpoint','picnic_site','toilets','drinking_water','parking','camp_site','peak'] -const facilityGroups:Record={hut:['alpine_hut','wilderness_hut','chalet'],shelter:['shelter','lean_to'],viewpoint:['viewpoint'],picnic_site:['picnic_site'],toilets:['toilets'],drinking_water:['drinking_water'],parking:['parking'],camp_site:['camp_site'],peak:['peak']} -const facilityCache=new Map() -const facilityPending=new Map>() -let facilityIndexReady:Promise|null -async function ensureFacilityIndex(){ - if(!facilityIndexReady)facilityIndexReady=(async()=>{ - const client=await pool.connect() - try{ - await client.query('BEGIN') - await client.query("SET LOCAL statement_timeout='60s'") - await client.query(`CREATE MATERIALIZED VIEW IF NOT EXISTS norwegian_explorer_app.route_facility_segments AS +const kinds = [ + 'alpine_hut', + 'wilderness_hut', + 'chalet', + 'shelter', + 'lean_to', + 'viewpoint', + 'picnic_site', + 'toilets', + 'drinking_water', + 'parking', + 'camp_site', + 'peak', +] +const facilityGroups: Record = { + hut: ['alpine_hut', 'wilderness_hut', 'chalet'], + shelter: ['shelter', 'lean_to'], + viewpoint: ['viewpoint'], + picnic_site: ['picnic_site'], + toilets: ['toilets'], + drinking_water: ['drinking_water'], + parking: ['parking'], + camp_site: ['camp_site'], + peak: ['peak'], +} +const facilityCache = new Map() +const facilityPending = new Map>() +let facilityIndexReady: Promise | null +async function ensureFacilityIndex() { + if (!facilityIndexReady) + facilityIndexReady = (async () => { + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query("SET LOCAL statement_timeout='60s'") + await client.query(`CREATE MATERIALIZED VIEW IF NOT EXISTS norwegian_explorer_app.route_facility_segments AS SELECT id,ST_Transform(part,4326)::geography geog FROM ( SELECT id,ST_Subdivide(geom,128) part FROM norwegian_explorer_app.route_catalog UNION ALL SELECT id,ST_Subdivide(geom,128) part FROM norwegian_explorer_app.route_loops ) parts`) - await client.query('CREATE INDEX IF NOT EXISTS route_facility_segments_geog_idx ON norwegian_explorer_app.route_facility_segments USING gist(geog)') - await client.query('ANALYZE norwegian_explorer_app.route_facility_segments') - await client.query('COMMIT') - }catch(error){await client.query('ROLLBACK');throw error}finally{client.release()} - })().catch(error=>{facilityIndexReady=null;throw error}) + await client.query( + 'CREATE INDEX IF NOT EXISTS route_facility_segments_geog_idx ON norwegian_explorer_app.route_facility_segments USING gist(geog)', + ) + await client.query('ANALYZE norwegian_explorer_app.route_facility_segments') + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + })().catch((error) => { + facilityIndexReady = null + throw error + }) await facilityIndexReady } -async function loadFacilityRouteIds(facility:string){ - if(!Object.hasOwn(facilityGroups,facility))throw new GraphQLError('Ugyldig kategori for steder nær ruten.') - const cached=facilityCache.get(facility) - if(cached&&cached.expires>Date.now())return cached.ids +async function loadFacilityRouteIds(facility: string) { + if (!Object.hasOwn(facilityGroups, facility)) + throw new GraphQLError('Ugyldig kategori for steder nær ruten.') + const cached = facilityCache.get(facility) + if (cached && cached.expires > Date.now()) return cached.ids await ensureFacilityIndex() - const ids=new Set() - for(let offset=0;;offset+=5000){ - const {rows}=await pool.query(`WITH places AS MATERIALIZED ( + const ids = new Set() + for (let offset = 0; ; offset += 5000) { + const { rows } = await pool.query( + `WITH places AS MATERIALIZED ( SELECT ST_PointOnSurface(geom)::geography point FROM osm_import.objects WHERE category='place' AND NOT ST_IsEmpty(geom) AND coalesce(tags->>'tourism',tags->>'amenity',tags->>'natural')=ANY($1::text[]) AND coalesce(tags->>'access','') NOT IN ('no','private') AND coalesce(tags->>'foot','') NOT IN ('no','private') ORDER BY osm_type,osm_id LIMIT 5000 OFFSET $2 ) SELECT (SELECT count(*)::int FROM places) count, ARRAY(SELECT DISTINCT s.id FROM places p - JOIN norwegian_explorer_app.route_facility_segments s ON ST_DWithin(s.geog,p.point,500)) ids`,[facilityGroups[facility],offset]) - for(const id of rows[0].ids)ids.add(id) - if(rows[0].count<5000)break + JOIN norwegian_explorer_app.route_facility_segments s ON ST_DWithin(s.geog,p.point,500)) ids`, + [facilityGroups[facility], offset], + ) + for (const id of rows[0].ids) ids.add(id) + if (rows[0].count < 5000) break } - const result=[...ids];facilityCache.set(facility,{ids:result,expires:Date.now()+600000});return result + const result = [...ids] + facilityCache.set(facility, { ids: result, expires: Date.now() + 600000 }) + return result } -export async function nearbyFacilityRouteIds(facility:string){ - if(!Object.hasOwn(facilityGroups,facility))throw new GraphQLError('Ugyldig kategori for steder nær ruten.') - if(!facilityPending.has(facility))facilityPending.set(facility,loadFacilityRouteIds(facility).finally(()=>facilityPending.delete(facility))) +export async function nearbyFacilityRouteIds(facility: string) { + if (!Object.hasOwn(facilityGroups, facility)) + throw new GraphQLError('Ugyldig kategori for steder nær ruten.') + if (!facilityPending.has(facility)) + facilityPending.set( + facility, + loadFacilityRouteIds(facility).finally(() => facilityPending.delete(facility)), + ) return facilityPending.get(facility) } -export async function routeStops(geojson:string,ids:string[]|null=null){ - if(ids && ids.length===0)return {stops:[],truncated:false} +export async function routeStops(geojson: string, ids: string[] | null = null) { + if (ids && ids.length === 0) return { stops: [], truncated: false } sampleRoute(geojson) - if(ids && (ids.length>20 || ids.some(id=>!/^([nw])\/[0-9]+$/.test(id)) || new Set(ids).size!==ids.length))throw new GraphQLError('Velg maks 20 forskjellige stopp.') - const {rows}=await pool.query(`WITH route AS ( + if ( + ids && + (ids.length > 20 || + ids.some((id) => !/^([nw])\/[0-9]+$/.test(id)) || + new Set(ids).size !== ids.length) + ) + throw new GraphQLError('Velg maks 20 forskjellige stopp.') + const { rows } = await pool.query( + `WITH route AS ( SELECT ST_SetSRID(ST_GeomFromGeoJSON($1),4326) geom ), candidates AS ( SELECT o.osm_type || '/' || o.osm_id::text id,o.name,o.tags,ST_PointOnSurface(o.geom) point,r.geom route, @@ -73,9 +125,21 @@ export async function routeStops(geojson:string,ids:string[]|null=null){ ST_LineLocatePoint(ST_Transform(route,25833),ST_Transform(point,25833))*ST_Length(route::geography) AS "distanceFromStartMeters", tags->>'operator' operator,tags->>'opening_hours' AS "openingHours",tags->>'capacity' capacity, tags->>'fee' fee,coalesce(tags->>'website',tags->>'contact:website') website - FROM nearby ORDER BY distance,id LIMIT 201`,[geojson,ids,kinds]) - const stops=rows.slice(0,200).map(row=>({...row,website:row.website&&/^https?:\/\//i.test(row.website)?row.website:null})) - .sort((a,b)=>a.distanceFromStartMeters-b.distanceFromStartMeters||a.id.localeCompare(b.id)) - if(ids && stops.length!==ids.length)throw new GraphQLError('Et valgt stopp finnes ikke lenger eller ligger mer enn 500 meter fra den nye ruten. Oppdater stoppene før lagring.') - return {stops,truncated:rows.length>200} + FROM nearby ORDER BY distance,id LIMIT 201`, + [geojson, ids, kinds], + ) + const stops = rows + .slice(0, 200) + .map((row) => ({ + ...row, + website: row.website && /^https?:\/\//i.test(row.website) ? row.website : null, + })) + .sort( + (a, b) => a.distanceFromStartMeters - b.distanceFromStartMeters || a.id.localeCompare(b.id), + ) + if (ids && stops.length !== ids.length) + throw new GraphQLError( + 'Et valgt stopp finnes ikke lenger eller ligger mer enn 500 meter fra den nye ruten. Oppdater stoppene før lagring.', + ) + return { stops, truncated: rows.length > 200 } } diff --git a/server/server.ts b/server/server.ts index 99219b9..06c5415 100644 --- a/server/server.ts +++ b/server/server.ts @@ -6,7 +6,12 @@ import { getAreaPointsOfInterest, searchPointsOfInterest } from './pointsOfInter // «extend type Query» legger spørringene til i Query-typen som er definert i index.ts export const mountainTypeDefs = /* GraphQL */ ` - enum PoiKind { HUT CABIN SHELTER PEAK } + enum PoiKind { + HUT + CABIN + SHELTER + PEAK + } type PointOfInterest { id: ID! @@ -59,7 +64,9 @@ export const mountainTypeDefs = /* GraphQL */ ` export const mountainResolvers = { Query: { mountainAreas: () => getMountainAreas(), - areaPointsOfInterest: (_: unknown, { areaId }: { areaId: string }) => getAreaPointsOfInterest(areaId), - searchPointsOfInterest: (_: unknown, { query }: { query: string }) => searchPointsOfInterest(query), + areaPointsOfInterest: (_: unknown, { areaId }: { areaId: string }) => + getAreaPointsOfInterest(areaId), + searchPointsOfInterest: (_: unknown, { query }: { query: string }) => + searchPointsOfInterest(query), }, } diff --git a/src/App.css b/src/App.css index 8dc9638..8e42207 100644 --- a/src/App.css +++ b/src/App.css @@ -1,4 +1,79 @@ -header{height:90px;padding:0 5%;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dce1d7;background:#fff}main{max-width:1450px;margin:auto;padding:44px 5% 20px}.eyebrow{font-size:11px;letter-spacing:2px;font-weight:700;color:#557365}h1{font-size:clamp(34px,5vw,58px);letter-spacing:-2px;margin:10px 0}dl{margin:0}dl>div{padding:13px 0;border-top:1px solid #e9ece5}dt{font-size:12px;color:#647267;margin-bottom:5px}dd{margin:0;font-size:14px;font-weight:500}.map{position:absolute;inset:0}footer{font-size:11px;color:#687669;margin:24px 0}.skip-link{position:absolute;top:-100px;left:10px;padding:10px;background:white;z-index:10}.skip-link:focus{top:10px}@media(max-width:800px){header{height:72px}main{padding-top:28px}} - -button:disabled{opacity:.4;cursor:default}input:focus-visible{outline:3px solid #c27730;outline-offset:3px} +header { + height: 90px; + padding: 0 5%; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #dce1d7; + background: #fff; +} +main { + max-width: 1450px; + margin: auto; + padding: 44px 5% 20px; +} +.eyebrow { + font-size: 11px; + letter-spacing: 2px; + font-weight: 700; + color: #557365; +} +h1 { + font-size: clamp(34px, 5vw, 58px); + letter-spacing: -2px; + margin: 10px 0; +} +dl { + margin: 0; +} +dl > div { + padding: 13px 0; + border-top: 1px solid #e9ece5; +} +dt { + font-size: 12px; + color: #647267; + margin-bottom: 5px; +} +dd { + margin: 0; + font-size: 14px; + font-weight: 500; +} +.map { + position: absolute; + inset: 0; +} +footer { + font-size: 11px; + color: #687669; + margin: 24px 0; +} +.skip-link { + position: absolute; + top: -100px; + left: 10px; + padding: 10px; + background: white; + z-index: 10; +} +.skip-link:focus { + top: 10px; +} +@media (max-width: 800px) { + header { + height: 72px; + } + main { + padding-top: 28px; + } +} +button:disabled { + opacity: 0.4; + cursor: default; +} +input:focus-visible { + outline: 3px solid #c27730; + outline-offset: 3px; +} diff --git a/src/App.tsx b/src/App.tsx index 894fa0f..0242131 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,49 +5,199 @@ import './App.css' import Explore from './Explore/Explore' import type { SelectedRoute } from './Explore/routeTypes' import Home from './Home' -import {graphql,TOKEN_KEY} from './graphql' +import { graphql, TOKEN_KEY } from './graphql' import Profile, { AuthGate } from './Profile' export default function App() { - const [view,setView] = useState<'explore'|'profile'>(()=>window.location.hash.startsWith('#profile')?'profile':'explore') - const [showHome,setShowHome]=useState(()=>!(window.location.hash==='#explore' || window.location.hash.startsWith('#profile') || window.location.hash.startsWith('#reset-password/'))) - const [showLogin,setShowLogin]=useState(()=>window.location.hash.startsWith('#reset-password/')) - const [guest,setGuest]=useState(()=>window.location.hash==='#explore' || (window.location.hash.startsWith('#profile') && Boolean(localStorage.getItem(TOKEN_KEY)))) + const [view, setView] = useState<'explore' | 'profile'>(() => + window.location.hash.startsWith('#profile') ? 'profile' : 'explore', + ) + const [showHome, setShowHome] = useState( + () => + !( + window.location.hash === '#explore' || + window.location.hash.startsWith('#profile') || + window.location.hash.startsWith('#reset-password/') + ), + ) + const [showLogin, setShowLogin] = useState(() => + window.location.hash.startsWith('#reset-password/'), + ) + const [guest, setGuest] = useState( + () => + window.location.hash === '#explore' || + (window.location.hash.startsWith('#profile') && Boolean(localStorage.getItem(TOKEN_KEY))), + ) const [authenticated, setAuthenticated] = useState(false) - const handleAuthenticated = useCallback(() => {setAuthenticated(true);setGuest(false);setShowLogin(false)}, []) - const handleLogout = useCallback(() => {setAuthenticated(false);setGuest(false);setShowLogin(false);setShowHome(true)}, []) - useEffect(()=>{ - const token=localStorage.getItem(TOKEN_KEY) - if(!token)return - const controller=new AbortController() - void graphql<{me:{username:string}|null}>('query($token:String!){me(token:$token){username}}',{token},controller.signal) - .then(data=>{if(!controller.signal.aborted&&localStorage.getItem(TOKEN_KEY)===token){setAuthenticated(Boolean(data.me));if(!data.me)localStorage.removeItem(TOKEN_KEY)}}).catch(()=>undefined) - return ()=>controller.abort() - },[]) + const handleAuthenticated = useCallback(() => { + setAuthenticated(true) + setGuest(false) + setShowLogin(false) + }, []) + const handleLogout = useCallback(() => { + setAuthenticated(false) + setGuest(false) + setShowLogin(false) + setShowHome(true) + }, []) + useEffect(() => { + const token = localStorage.getItem(TOKEN_KEY) + if (!token) return + const controller = new AbortController() + void graphql<{ me: { username: string } | null }>( + 'query($token:String!){me(token:$token){username}}', + { token }, + controller.signal, + ) + .then((data) => { + if (!controller.signal.aborted && localStorage.getItem(TOKEN_KEY) === token) { + setAuthenticated(Boolean(data.me)) + if (!data.me) localStorage.removeItem(TOKEN_KEY) + } + }) + .catch(() => undefined) + return () => controller.abort() + }, []) // Valgt tur deles mellom Utforsk, forsiden og profilen, derfor ligger den her const [selected, setSelected] = useState(null) - const [loadedRoute, setLoadedRoute] = useState<{ id: string; name: string; number: string } | null>(null) + const [loadedRoute, setLoadedRoute] = useState<{ + id: string + name: string + number: string + } | null>(null) function openRoute(item: SelectedRoute) { setView('explore') setSelected({ ...item }) // Turen vises i panelet oppå kartet, som ligger øverst på Utforsk-siden window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: 'smooth' })) } - if(showLogin&&(!authenticated||window.location.hash.startsWith('#reset-password/')))return {setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} /> - if(showHome)return {setGuest(!authenticated);setShowHome(false);setView('explore')}} onProfileClick={()=>{setView('profile');setShowHome(false)}} onLoginClick={()=>setShowLogin(true)} onSelectRoute={item=>{openRoute(item);setGuest(!authenticated);setShowHome(false)}} /> - if(!authenticated&&!guest)return setShowHome(true)} onSkip={()=>{setGuest(true);setView('explore')}} /> + if (showLogin && (!authenticated || window.location.hash.startsWith('#reset-password/'))) + return ( + { + setShowLogin(false) + setShowHome(true) + }} + onSkip={() => { + setGuest(true) + setShowLogin(false) + setShowHome(false) + setView('explore') + }} + /> + ) + if (showHome) + return ( + { + setGuest(!authenticated) + setShowHome(false) + setView('explore') + }} + onProfileClick={() => { + setView('profile') + setShowHome(false) + }} + onLoginClick={() => setShowLogin(true)} + onSelectRoute={(item) => { + openRoute(item) + setGuest(!authenticated) + setShowHome(false) + }} + /> + ) + if (!authenticated && !guest) + return ( + setShowHome(true)} + onSkip={() => { + setGuest(true) + setView('explore') + }} + /> + ) - return
- Hopp til ruteinformasjon - setShowHome(true)} onExplore={()=>{setView('explore');window.scrollTo({top:0,behavior:'smooth'})}} onProfile={()=>{if(!authenticated){setGuest(false);setShowLogin(true)}setView('profile');window.scrollTo({top:0,behavior:'smooth'})}} /> -
- - - -
-
+ return ( +
+ + Hopp til ruteinformasjon + + setShowHome(true)} + onExplore={() => { + setView('explore') + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + onProfile={() => { + if (!authenticated) { + setGuest(false) + setShowLogin(true) + } + setView('profile') + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + /> +
+ + +
+ <> + Rutedata: Kartverket ·{' '} + CC BY 4.0. Rutelinjen er + bearbeidet fra originale rutedeler. + +
+
+
+ ) } diff --git a/src/ElevationProfile.css b/src/ElevationProfile.css index 51a9890..d097f3a 100644 --- a/src/ElevationProfile.css +++ b/src/ElevationProfile.css @@ -1,13 +1,63 @@ -.elevation-panel { margin: 24px 0; padding: 20px; background: #fff; border: 1px solid #dce1d7; border-radius: 12px; } -.elevation-panel h3 { margin-top: 0; } -.elevation-panel svg { display: block; width: 100%; max-width: 900px; margin: 20px 0; } -.elevation-panel svg text { fill: #647267; font-size: 11px; } -.elevation-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; margin-top: 18px; } -.elevation-stats strong,.elevation-stats span { display: block; } -.elevation-stats span { color: #647267; font-size: 12px; margin-top: 6px; } -.elevation-slider { display: grid; gap: 8px; font-size: 13px; } -.elevation-slider input { width: 100%; accent-color: #126b51; } -.elevation-table { overflow: auto; max-height: 300px; } -.elevation-table table { border-collapse: collapse; width: 100%; } -.elevation-table td,.elevation-table th { text-align: left; padding: 8px; border-bottom: 1px solid #dce1d7; } -@media(max-width:600px){.elevation-stats{grid-template-columns:repeat(2,minmax(0,1fr))}} +.elevation-panel { + margin: 24px 0; + padding: 20px; + background: #fff; + border: 1px solid #dce1d7; + border-radius: 12px; +} +.elevation-panel h3 { + margin-top: 0; +} +.elevation-panel svg { + display: block; + width: 100%; + max-width: 900px; + margin: 20px 0; +} +.elevation-panel svg text { + fill: #647267; + font-size: 11px; +} +.elevation-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} +.elevation-stats strong, +.elevation-stats span { + display: block; +} +.elevation-stats span { + color: #647267; + font-size: 12px; + margin-top: 6px; +} +.elevation-slider { + display: grid; + gap: 8px; + font-size: 13px; +} +.elevation-slider input { + width: 100%; + accent-color: #126b51; +} +.elevation-table { + overflow: auto; + max-height: 300px; +} +.elevation-table table { + border-collapse: collapse; + width: 100%; +} +.elevation-table td, +.elevation-table th { + text-align: left; + padding: 8px; + border-bottom: 1px solid #dce1d7; +} +@media (max-width: 600px) { + .elevation-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/ElevationProfile.tsx b/src/ElevationProfile.tsx index a008110..b7bfdc0 100644 --- a/src/ElevationProfile.tsx +++ b/src/ElevationProfile.tsx @@ -1,60 +1,216 @@ -import {useEffect,useRef,useState} from 'react' -import {graphql} from './graphql' +import { useEffect, useRef, useState } from 'react' +import { graphql } from './graphql' import './ElevationProfile.css' -type Point={distanceMeters:number;elevation:number|null} -type Profile={points:Point[];lengthMeters:number;sampleIntervalMeters:number;ascentMeters:number|null;descentMeters:number|null;minElevation:number|null;maxElevation:number|null;complete:boolean;missingCount:number;source:string} -function meters(value:number|null){return value==null?'Ikke tilgjengelig':`${Math.round(value).toLocaleString('nb-NO')} m`} +type Point = { distanceMeters: number; elevation: number | null } +type Profile = { + points: Point[] + lengthMeters: number + sampleIntervalMeters: number + ascentMeters: number | null + descentMeters: number | null + minElevation: number | null + maxElevation: number | null + complete: boolean + missingCount: number + source: string +} +function meters(value: number | null) { + return value == null ? 'Ikke tilgjengelig' : `${Math.round(value).toLocaleString('nb-NO')} m` +} -export default function ElevationProfile({geojson}:{geojson:string}){ - const [result,setResult]=useState<{geojson:string;profile:Profile}|null>(null) - const [error,setError]=useState<{geojson:string;text:string}|null>(null) - const [busyGeo,setBusyGeo]=useState(null) - const [selected,setSelected]=useState(0) - const request=useRef(null) - useEffect(()=>()=>request.current?.abort(),[geojson]) - const profile=result?.geojson===geojson?result.profile:null - const busy=busyGeo===geojson - let connected=false - try{connected=JSON.parse(geojson).type==='LineString'}catch{ /* A malformed route cannot have a profile. */ } +export default function ElevationProfile({ geojson }: { geojson: string }) { + const [result, setResult] = useState<{ geojson: string; profile: Profile } | null>(null) + const [error, setError] = useState<{ geojson: string; text: string } | null>(null) + const [busyGeo, setBusyGeo] = useState(null) + const [selected, setSelected] = useState(0) + const request = useRef(null) + useEffect(() => () => request.current?.abort(), [geojson]) + const profile = result?.geojson === geojson ? result.profile : null + const busy = busyGeo === geojson + let connected = false + try { + connected = JSON.parse(geojson).type === 'LineString' + } catch { + /* A malformed route cannot have a profile. */ + } - async function load(){ - request.current?.abort();const controller=new AbortController();request.current=controller - const token=localStorage.getItem('norwegian-explorer-session')||'' - setBusyGeo(geojson);setError(null) - try{ - const data=await graphql<{elevationProfile:Profile}>('query($token:String!,$geojson:String!){elevationProfile(token:$token,geojson:$geojson){points{distanceMeters elevation} lengthMeters sampleIntervalMeters ascentMeters descentMeters minElevation maxElevation complete missingCount source}}',{token,geojson},AbortSignal.any([controller.signal,AbortSignal.timeout(25000)])) - if(!controller.signal.aborted){setResult({geojson,profile:data.elevationProfile});setSelected(0)} - }catch(err){if(!controller.signal.aborted)setError({geojson,text:err instanceof Error?err.message:'Kunne ikke hente høydeprofil.'})} - finally{if(request.current===controller)setBusyGeo(null)} + async function load() { + request.current?.abort() + const controller = new AbortController() + request.current = controller + const token = localStorage.getItem('norwegian-explorer-session') || '' + setBusyGeo(geojson) + setError(null) + try { + const data = await graphql<{ elevationProfile: Profile }>( + 'query($token:String!,$geojson:String!){elevationProfile(token:$token,geojson:$geojson){points{distanceMeters elevation} lengthMeters sampleIntervalMeters ascentMeters descentMeters minElevation maxElevation complete missingCount source}}', + { token, geojson }, + AbortSignal.any([controller.signal, AbortSignal.timeout(25000)]), + ) + if (!controller.signal.aborted) { + setResult({ geojson, profile: data.elevationProfile }) + setSelected(0) + } + } catch (err) { + if (!controller.signal.aborted) + setError({ + geojson, + text: err instanceof Error ? err.message : 'Kunne ikke hente høydeprofil.', + }) + } finally { + if (request.current === controller) setBusyGeo(null) + } } - const low=(profile?.minElevation??0)-5,high=(profile?.maxElevation??0)+5 - const x=(distance:number)=>48+distance/(profile?.lengthMeters||1)*564 - const y=(elevation:number)=>176-(elevation-low)/(high-low)*144 - let path='',pen=false - if(profile)for(const point of profile.points){if(point.elevation==null){pen=false;continue}path+=`${pen?'L':'M'}${x(point.distanceMeters).toFixed(2)},${y(point.elevation).toFixed(2)} `;pen=true} - const point=profile?.points[Math.min(selected,(profile?.points.length||1)-1)] - return
-

Høydeprofil

- {!connected?

Velg en sammenhengende rute eller rutevariant for å vise høydeprofil. Flere adskilte kartlinjer har ingen entydig turrekkefølge.

:<> -

Høydene hentes fra Kartverket når du trykker på knappen. Utvalgte rutekoordinater sendes til tjenesten.

- - {busy&&

Henter terrenghøyder langs ruten …

} - {error?.geojson===geojson&&

{error.text}

} - {profile&&<> -
{meters(profile.ascentMeters)}Opp
{meters(profile.descentMeters)}Ned
{meters(profile.minElevation)}Laveste målte punkt
{meters(profile.maxElevation)}Høyeste målte punkt
- - - {Math.round(high)} m{Math.round(low)} m0 km{(profile.lengthMeters/1000).toLocaleString('nb-NO',{maximumFractionDigits:1})} km - - {point?.elevation!=null&&} - - - {point&&

{(point.distanceMeters/1000).toLocaleString('nb-NO',{maximumFractionDigits:2})} km fra start · {meters(point.elevation)} over havet

} - {!profile.complete&&

Høyde mangler for {profile.missingCount} punkter. Kurven har hull, og samlet opp/ned vises ikke.

} -

Omtrentlige terrenghøyder med cirka {Math.round(profile.sampleIntervalMeters)} meter mellom målepunktene. Stigning og høyeste/laveste punkt er estimater fra utvalget og kan overse korte bakker. Kilde: {profile.source}.

-
Høydene som tabell
{profile.points.map((p,i)=>)}
Fra start (km)Høyde (m)
{(p.distanceMeters/1000).toLocaleString('nb-NO',{maximumFractionDigits:2})}{p.elevation==null?'Mangler':Math.round(p.elevation)}
- } - } -
+ const low = (profile?.minElevation ?? 0) - 5, + high = (profile?.maxElevation ?? 0) + 5 + const x = (distance: number) => 48 + (distance / (profile?.lengthMeters || 1)) * 564 + const y = (elevation: number) => 176 - ((elevation - low) / (high - low)) * 144 + let path = '', + pen = false + if (profile) + for (const point of profile.points) { + if (point.elevation == null) { + pen = false + continue + } + path += `${pen ? 'L' : 'M'}${x(point.distanceMeters).toFixed(2)},${y(point.elevation).toFixed(2)} ` + pen = true + } + const point = profile?.points[Math.min(selected, (profile?.points.length || 1) - 1)] + return ( +
+

Høydeprofil

+ {!connected ? ( +

+ Velg en sammenhengende rute eller rutevariant for å vise høydeprofil. Flere adskilte + kartlinjer har ingen entydig turrekkefølge. +

+ ) : ( + <> +

+ Høydene hentes fra Kartverket når du trykker på knappen. Utvalgte rutekoordinater sendes + til tjenesten. +

+ + {busy &&

Henter terrenghøyder langs ruten …

} + {error?.geojson === geojson &&

{error.text}

} + {profile && ( + <> +
+
+ {meters(profile.ascentMeters)} + Opp +
+
+ {meters(profile.descentMeters)} + Ned +
+
+ {meters(profile.minElevation)} + Laveste målte punkt +
+
+ {meters(profile.maxElevation)} + Høyeste målte punkt +
+
+ + + + + {Math.round(high)} m + + + {Math.round(low)} m + + + 0 km + + + {(profile.lengthMeters / 1000).toLocaleString('nb-NO', { + maximumFractionDigits: 1, + })}{' '} + km + + + {point?.elevation != null && ( + + )} + + + {point && ( +

+ {(point.distanceMeters / 1000).toLocaleString('nb-NO', { + maximumFractionDigits: 2, + })}{' '} + km fra start · {meters(point.elevation)} over havet +

+ )} + {!profile.complete && ( +

+ Høyde mangler for {profile.missingCount} punkter. Kurven har hull, og samlet + opp/ned vises ikke. +

+ )} +

+ Omtrentlige terrenghøyder med cirka {Math.round(profile.sampleIntervalMeters)} meter + mellom målepunktene. Stigning og høyeste/laveste punkt er estimater fra utvalget og + kan overse korte bakker.{' '} + + Kilde: {profile.source} + + . +

+
+ Høydene som tabell +
+ + + + + + + + + {profile.points.map((p, i) => ( + + + + + ))} + +
Fra start (km)Høyde (m)
+ {(p.distanceMeters / 1000).toLocaleString('nb-NO', { + maximumFractionDigits: 2, + })} + {p.elevation == null ? 'Mangler' : Math.round(p.elevation)}
+
+
+ + )} + + )} +
+ ) } diff --git a/src/Explore/Explore.css b/src/Explore/Explore.css index 53df50a..51c899c 100644 --- a/src/Explore/Explore.css +++ b/src/Explore/Explore.css @@ -1,35 +1,164 @@ -.explore-app,.auth-page{--ink:#23382c;--muted:#758075;--green:#435d47;--line:#e3e8df;--paper:#fafbf8;font-family:'Avenir Next',system-ui,sans-serif;color:var(--ink);background:var(--paper)} -.explore-app [hidden]{display:none!important} -.explore-app>header,.auth-page>header{height:86px;padding:0 max(5%,calc((100vw - 1320px)/2));background:var(--paper);border-bottom:1px solid var(--line)} +.explore-app, +.auth-page { + --ink: #23382c; + --muted: #758075; + --green: #435d47; + --line: #e3e8df; + --paper: #fafbf8; + font-family: 'Avenir Next', system-ui, sans-serif; + color: var(--ink); + background: var(--paper); +} +.explore-app [hidden] { + display: none !important; +} +.explore-app > header, +.auth-page > header { + height: 86px; + padding: 0 max(5%, calc((100vw - 1320px) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} -.explore-app button,.auth-page button{background:var(--green);border-radius:9px;font-weight:600;padding:12px 18px;transition:background .15s,transform .15s} -.explore-app button:hover:not(:disabled),.auth-page button:hover:not(:disabled){background:#304c37} +.explore-app button, +.auth-page button { + background: var(--green); + border-radius: 9px; + font-weight: 600; + padding: 12px 18px; + transition: + background 0.15s, + transform 0.15s; +} +.explore-app button:hover:not(:disabled), +.auth-page button:hover:not(:disabled) { + background: #304c37; +} -.explore-app main{max-width:1432px;padding:24px 56px 24px} +.explore-app main { + max-width: 1432px; + padding: 24px 56px 24px; +} -.visually-hidden{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} -.explore-app .text-button{background:transparent;color:var(--green)}.explore-app .text-button:hover:not(:disabled){background:transparent;color:#263c2b} -.profile-page-title{margin:32px 0}.profile-page-title h1{font-size:44px;letter-spacing:-1.6px}.profile-page-title>p:last-child{color:var(--muted);font-size:14px} -.explore-app .profile-panel{margin-top:24px;border-radius:12px;padding:30px}.explore-app .profile-tabs button{background:#eef2ea;color:var(--ink)} -.auth-page{background:#f4f6ef}.auth-main{padding:48px 24px}.auth-card{border-radius:14px;box-shadow:0 16px 45px #243a3010;padding:42px}.auth-intro h1{font-family:'Avenir Next',system-ui,sans-serif;font-size:44px;font-weight:600;letter-spacing:-1.8px}.auth-card .profile-form input{background:#fafbf8;border-color:#dce3d5;padding:14px}.auth-card .text-button{background:transparent;color:var(--green)} -@media(max-width:1050px){.explore-app main{padding:20px 28px}} -@media(max-width:720px){.explore-app>header,.auth-page>header{padding:0 20px;height:74px}.explore-app main{padding:16px}.auth-card{padding:28px}.explore-app .profile-panel{padding:20px}} +.explore-app .text-button { + background: transparent; + color: var(--green); +} +.explore-app .text-button:hover:not(:disabled) { + background: transparent; + color: #263c2b; +} +.profile-page-title { + margin: 32px 0; +} +.profile-page-title h1 { + font-size: 44px; + letter-spacing: -1.6px; +} +.profile-page-title > p:last-child { + color: var(--muted); + font-size: 14px; +} +.explore-app .profile-panel { + margin-top: 24px; + border-radius: 12px; + padding: 30px; +} +.explore-app .profile-tabs button { + background: #eef2ea; + color: var(--ink); +} +.auth-page { + background: #f4f6ef; +} +.auth-main { + padding: 48px 24px; +} +.auth-card { + border-radius: 14px; + box-shadow: 0 16px 45px #243a3010; + padding: 42px; +} +.auth-intro h1 { + font-family: 'Avenir Next', system-ui, sans-serif; + font-size: 44px; + font-weight: 600; + letter-spacing: -1.8px; +} +.auth-card .profile-form input { + background: #fafbf8; + border-color: #dce3d5; + padding: 14px; +} +.auth-card .text-button { + background: transparent; + color: var(--green); +} +@media (max-width: 1050px) { + .explore-app main { + padding: 20px 28px; + } +} +@media (max-width: 720px) { + .explore-app > header, + .auth-page > header { + padding: 0 20px; + height: 74px; + } + .explore-app main { + padding: 16px; + } + .auth-card { + padding: 28px; + } + .explore-app .profile-panel { + padding: 20px; + } +} -@media(prefers-reduced-motion:reduce){.explore-app button{transition:none}} +@media (prefers-reduced-motion: reduce) { + .explore-app button { + transition: none; + } +} /* Give closed native selects the same appearance across browsers. */ -.explore-app select{ - -webkit-appearance:none;appearance:none; - display:block;max-width:100%;min-height:46px; - padding:12px 42px 12px 14px!important; - border:1px solid #d9e1d3!important;border-radius:9px!important; - background-color:#fff!important; - background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23566b50' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")!important; - background-repeat:no-repeat!important;background-position:right 14px center!important; - color:#293e2e!important;font-family:inherit;font-size:13px!important;line-height:1.4; - box-shadow:none;cursor:pointer; -} -.explore-app select:hover{border-color:#9bad91!important} -.explore-app select:focus-visible{outline:2px solid #677f5d;outline-offset:3px} -.explore-app select:disabled{opacity:.5;cursor:default} - +.explore-app select { + -webkit-appearance: none; + appearance: none; + display: block; + max-width: 100%; + min-height: 46px; + padding: 12px 42px 12px 14px !important; + border: 1px solid #d9e1d3 !important; + border-radius: 9px !important; + background-color: #fff !important; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23566b50' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important; + background-repeat: no-repeat !important; + background-position: right 14px center !important; + color: #293e2e !important; + font-family: inherit; + font-size: 13px !important; + line-height: 1.4; + box-shadow: none; + cursor: pointer; +} +.explore-app select:hover { + border-color: #9bad91 !important; +} +.explore-app select:focus-visible { + outline: 2px solid #677f5d; + outline-offset: 3px; +} +.explore-app select:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/src/Explore/Explore.tsx b/src/Explore/Explore.tsx index 36a8b46..55529e8 100644 --- a/src/Explore/Explore.tsx +++ b/src/Explore/Explore.tsx @@ -8,14 +8,30 @@ import './Explore.css' import ExploreMap, { type MapFocus } from './ExploreMap' import ExploreSearchPanel, { type PlaceHit } from './ExploreSearchPanel' import ExploreFilters from './ExploreFilters' -import { EMPTY_FILTERS, PAGE_SIZE, filterVariables, type Bounds, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes' +import { + EMPTY_FILTERS, + PAGE_SIZE, + filterVariables, + type Bounds, + type Route, + type RouteFilterValue, + type RouteGroup, + type RouteSort, + type RouteSummary, + type SelectedRoute, +} from './routeTypes' import { graphql, TOKEN_KEY } from '../graphql' /** Hvor kartet starter når brukeren ikke har valgt et sted i profilen */ const OSLO: Bounds = { west: 10.48, south: 59.81, east: 10.95, north: 60.14 } /** Utsnittet «Vis hele Norge» går til */ const NORWAY: Bounds = { west: 4.5, south: 57.9, east: 31.2, north: 71.2 } -const toBounds = (bbox: number[]): Bounds => ({ west: bbox[0], south: bbox[1], east: bbox[2], north: bbox[3] }) +const toBounds = (bbox: number[]): Bounds => ({ + west: bbox[0], + south: bbox[1], + east: bbox[2], + north: bbox[3], +}) const LIST_QUERY = `query($minLength:Float,$maxLength:Float,$difficulty:[String!],$routeType:[String!],$facility:[String!],$sort:RouteSort, $west:Float,$south:Float,$east:Float,$north:Float,$offset:Int!,$limit:Int!){ @@ -42,16 +58,28 @@ type Props = { onSaveRoute: () => void } -export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, authenticated, onSaveRoute }: Props) { +export default function Explore({ + selected, + onSelect, + onDeselect, + onRouteLoaded, + authenticated, + onSaveRoute, +}: Props) { // --- Valgt tur --- const [route, setRoute] = useState(null) const [routeVariants, setRouteVariants] = useState([]) - const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'empty'>(selected ? 'loading' : 'empty') + const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'empty'>( + selected ? 'loading' : 'empty', + ) const [attempt, setAttempt] = useState(0) // Når en ny tur velges (her eller fra forsiden/profilen), vis «henter» til den er lastet. // Justeres under rendering i stedet for i en effekt, slik React anbefaler. const [prevSelected, setPrevSelected] = useState(selected) - if (selected !== prevSelected) { setPrevSelected(selected); setStatus(selected ? 'loading' : 'empty') } + if (selected !== prevSelected) { + setPrevSelected(selected) + setStatus(selected ? 'loading' : 'empty') + } // --- Filtre og sortering --- const [filters, setFilters] = useState(EMPTY_FILTERS) @@ -63,7 +91,10 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, const [total, setTotal] = useState(0) const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading') /** Starter lista på nytt fra side 1 (når filtre, sortering eller kartutsnitt endres) */ - const restartList = () => { setPage(0); setListStatus('loading') } + const restartList = () => { + setPage(0) + setListStatus('loading') + } // --- Kartet --- // Kartutsnittet fra ExploreMap. null = ingen begrensning (hele Norge). @@ -71,7 +102,8 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, // Hvor kartet skal være. Starter i Oslo, eller på stedet brukeren har valgt i profilen (samme sted som forsiden // bruker). Endres også når man velger et sted i søket eller trykker «Vis hele Norge». const [focus, setFocus] = useState({ bounds: OSLO, key: 0 }) - const flyTo = (next: Bounds, clearFilter = false) => setFocus(previous => ({ bounds: next, key: previous.key + 1, clearFilter })) + const flyTo = (next: Bounds, clearFilter = false) => + setFocus((previous) => ({ bounds: next, key: previous.key + 1, clearFilter })) // Sted valgt fra søket i kartet. Vises som merkelapp, og fjernes når brukeren selv flytter kartet. const [place, setPlace] = useState<{ code: string; name: string } | null>(null) @@ -81,8 +113,11 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, if (!authenticated || !token) return const controller = new AbortController() graphql<{ me: { homeMunicipality: { bbox: number[] | null } | null } | null }>( - 'query($token:String!){me(token:$token){homeMunicipality{bbox}}}', { token }, controller.signal) - .then(data => { + 'query($token:String!){me(token:$token){homeMunicipality{bbox}}}', + { token }, + controller.signal, + ) + .then((data) => { const bbox = data.me?.homeMunicipality?.bbox if (bbox?.length === 4) flyTo(toBounds(bbox)) }) @@ -94,12 +129,28 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, // En ny forespørsel avbryter den forrige, så svar som kommer i feil rekkefølge aldri vises. useEffect(() => { const controller = new AbortController() - graphql<{ routes: { total: number; items: RouteGroup[] } }>(LIST_QUERY, { - ...filterVariables(filters), sort, offset: page * PAGE_SIZE, limit: PAGE_SIZE, - west: bounds?.west ?? null, south: bounds?.south ?? null, east: bounds?.east ?? null, north: bounds?.north ?? null, - }, controller.signal) - .then(data => { setItems(data.routes.items); setTotal(data.routes.total); setListStatus('ready') }) - .catch(() => { if (!controller.signal.aborted) setListStatus('error') }) + graphql<{ routes: { total: number; items: RouteGroup[] } }>( + LIST_QUERY, + { + ...filterVariables(filters), + sort, + offset: page * PAGE_SIZE, + limit: PAGE_SIZE, + west: bounds?.west ?? null, + south: bounds?.south ?? null, + east: bounds?.east ?? null, + north: bounds?.north ?? null, + }, + controller.signal, + ) + .then((data) => { + setItems(data.routes.items) + setTotal(data.routes.total) + setListStatus('ready') + }) + .catch(() => { + if (!controller.signal.aborted) setListStatus('error') + }) return () => controller.abort() }, [filters, sort, bounds, page]) @@ -107,33 +158,88 @@ export default function Explore({ selected, onSelect, onDeselect, onRouteLoaded, useEffect(() => { if (!selected) return const controller = new AbortController() - graphql<{ route: Route | null; routeVariants: RouteSummary[] }>(ROUTE_QUERY, { name: selected.name, number: selected.number }, controller.signal) - .then(data => { + graphql<{ route: Route | null; routeVariants: RouteSummary[] }>( + ROUTE_QUERY, + { name: selected.name, number: selected.number }, + controller.signal, + ) + .then((data) => { setRoute(data.route) setRouteVariants(data.routeVariants) setStatus(data.route ? 'ready' : 'empty') - onRouteLoaded(data.route ? { id: data.route.id, name: data.route.name, number: data.route.number } : null) + onRouteLoaded( + data.route + ? { id: data.route.id, name: data.route.name, number: data.route.number } + : null, + ) + }) + .catch(() => { + if (!controller.signal.aborted) setStatus('error') }) - .catch(() => { if (!controller.signal.aborted) setStatus('error') }) return () => controller.abort() }, [selected, attempt, onRouteLoaded]) - return
- { setBounds(next); restartList(); if (byUser) setPlace(null) }} /> - { setFilters(next); restartList() }} /> - { setSort(next); restartList() }} - items={items} total={total} listStatus={listStatus} page={page} - onPageChange={next => { setPage(next); setListStatus('loading') }} - filtered={bounds !== null} - onShowAll={() => { flyTo(NORWAY, true); setBounds(null); setPlace(null); restartList() }} - place={place} onClearPlace={() => setPlace(null)} - onChoosePlace={(hit: PlaceHit) => { if (hit.bbox?.length === 4) { setPlace({ code: hit.code, name: hit.name }); flyTo(toBounds(hit.bbox)) } }} - onChooseRoute={onSelect} - selected={selected} route={route} routeVariants={routeVariants} status={status} - onRetry={() => { setStatus('loading'); setAttempt(attempt + 1) }} - onBack={onDeselect} - authenticated={authenticated} onSaveRoute={onSaveRoute} /> -
+ return ( +
+ { + setBounds(next) + restartList() + if (byUser) setPlace(null) + }} + /> + { + setFilters(next) + restartList() + }} + /> + { + setSort(next) + restartList() + }} + items={items} + total={total} + listStatus={listStatus} + page={page} + onPageChange={(next) => { + setPage(next) + setListStatus('loading') + }} + filtered={bounds !== null} + onShowAll={() => { + flyTo(NORWAY, true) + setBounds(null) + setPlace(null) + restartList() + }} + place={place} + onClearPlace={() => setPlace(null)} + onChoosePlace={(hit: PlaceHit) => { + if (hit.bbox?.length === 4) { + setPlace({ code: hit.code, name: hit.name }) + flyTo(toBounds(hit.bbox)) + } + }} + onChooseRoute={onSelect} + selected={selected} + route={route} + routeVariants={routeVariants} + status={status} + onRetry={() => { + setStatus('loading') + setAttempt(attempt + 1) + }} + onBack={onDeselect} + authenticated={authenticated} + onSaveRoute={onSaveRoute} + /> +
+ ) } diff --git a/src/Explore/ExploreFilters.css b/src/Explore/ExploreFilters.css index 0904947..0187b66 100644 --- a/src/Explore/ExploreFilters.css +++ b/src/Explore/ExploreFilters.css @@ -1,33 +1,191 @@ /* Filterraden øverst i kartet (ExploreFilters.tsx). Ligger til høyre for søkepanelet på stor skjerm. */ -.ef { position: absolute; z-index: 3; top: 16px; left: 432px; right: 64px; font-family: 'Avenir Next', system-ui, sans-serif; pointer-events: none; } -.ef-row, .ef-menu { pointer-events: auto; } -.ef-row { display: flex; gap: 8px; overflow-x: auto; padding: 3px; scrollbar-width: none; } -.ef-row::-webkit-scrollbar { display: none; } -.explore-app .ef-pill { flex-shrink: 0; display: inline-flex; align-items: center; gap: 8px; background: #fff; color: #23382c; border: 1px solid #dce3d5; border-radius: 12px; padding: 10px 14px; font-size: 13px; font-weight: 600; white-space: nowrap; box-shadow: 0 2px 10px #23382c1f; } -.explore-app .ef-pill:hover:not(:disabled), .explore-app .ef-pill[aria-expanded=true] { background: #f4f7f1; border-color: #a2b398; } -.explore-app .ef-pill[data-active] { background: #435d47; border-color: #435d47; color: #fff; } -.explore-app .ef-pill[data-active]:hover:not(:disabled) { background: #304c37; } -.explore-app .ef-reset { color: #435d47; } -.ef-pill svg { transition: transform .15s; } -.ef-pill[aria-expanded=true] svg { transform: rotate(180deg); } -.ef-menu { position: absolute; top: calc(100% + 8px); width: min(340px, 100%); max-height: min(480px, calc(100vh - 220px)); overflow-y: auto; padding: 16px; background: #fff; color: #23382c; border-radius: 14px; box-shadow: 0 12px 36px #23382c38; } -.ef-menu h3 { margin: 0 0 12px; font-size: 14px; } -.ef-options { display: flex; flex-wrap: wrap; gap: 8px; } -.ef-options.ef-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } -.ef-options.ef-grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } -.ef-options.ef-stack { display: grid; } -.explore-app .ef-option { display: flex; align-items: center; gap: 10px; text-align: left; background: #fff; color: #23382c; border: 1px solid #dce3d5; border-radius: 12px; padding: 10px 12px; font-size: 13px; font-weight: 500; } -.explore-app .ef-option:hover:not(:disabled) { background: #f4f7f1; border-color: #a2b398; } -.explore-app .ef-option[aria-pressed=true] { background: #eef3e9; border-color: #435d47; box-shadow: inset 0 0 0 1px #435d47; } -.ef-option small { display: block; margin-top: 2px; font-size: 11px; font-weight: 400; color: #6b7768; } -.ef-option svg { flex-shrink: 0; color: #435d47; } -.ef-length-form { display: flex; align-items: end; gap: 8px; margin-top: 14px; } -.ef-length-form label { flex: 1; display: grid; gap: 4px; font-size: 11px; font-weight: 600; color: #6b7768; } -.ef-length-form input { width: 100%; min-width: 0; font: inherit; font-size: 13px; color: #23382c; border: 1px solid #dce3d5; border-radius: 9px; padding: 9px 10px; } -.explore-app .ef-length-form button { font-size: 12px; padding: 10px 14px; } -.ef-note { margin: 12px 0 0; font-size: 11px; line-height: 1.5; color: #6b7768; } -.ef-menu-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 12px; border-top: 1px solid #e3e8df; } -.explore-app .ef-menu-footer .text-button { padding: 0; font-size: 13px; } -.explore-app .ef-menu-footer button:last-child { font-size: 13px; padding: 9px 16px; } -@media (max-width: 720px) { .ef { top: 10px; left: 10px; right: 56px; } .ef-options.ef-grid-3 { grid-template-columns: 1fr; } } -@media (prefers-reduced-motion: reduce) { .ef-pill svg { transition: none; } } +.ef { + position: absolute; + z-index: 3; + top: 16px; + left: 432px; + right: 64px; + font-family: 'Avenir Next', system-ui, sans-serif; + pointer-events: none; +} +.ef-row, +.ef-menu { + pointer-events: auto; +} +.ef-row { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 3px; + scrollbar-width: none; +} +.ef-row::-webkit-scrollbar { + display: none; +} +.explore-app .ef-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 8px; + background: #fff; + color: #23382c; + border: 1px solid #dce3d5; + border-radius: 12px; + padding: 10px 14px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + box-shadow: 0 2px 10px #23382c1f; +} +.explore-app .ef-pill:hover:not(:disabled), +.explore-app .ef-pill[aria-expanded='true'] { + background: #f4f7f1; + border-color: #a2b398; +} +.explore-app .ef-pill[data-active] { + background: #435d47; + border-color: #435d47; + color: #fff; +} +.explore-app .ef-pill[data-active]:hover:not(:disabled) { + background: #304c37; +} +.explore-app .ef-reset { + color: #435d47; +} +.ef-pill svg { + transition: transform 0.15s; +} +.ef-pill[aria-expanded='true'] svg { + transform: rotate(180deg); +} +.ef-menu { + position: absolute; + top: calc(100% + 8px); + width: min(340px, 100%); + max-height: min(480px, calc(100vh - 220px)); + overflow-y: auto; + padding: 16px; + background: #fff; + color: #23382c; + border-radius: 14px; + box-shadow: 0 12px 36px #23382c38; +} +.ef-menu h3 { + margin: 0 0 12px; + font-size: 14px; +} +.ef-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.ef-options.ef-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.ef-options.ef-grid-3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.ef-options.ef-stack { + display: grid; +} +.explore-app .ef-option { + display: flex; + align-items: center; + gap: 10px; + text-align: left; + background: #fff; + color: #23382c; + border: 1px solid #dce3d5; + border-radius: 12px; + padding: 10px 12px; + font-size: 13px; + font-weight: 500; +} +.explore-app .ef-option:hover:not(:disabled) { + background: #f4f7f1; + border-color: #a2b398; +} +.explore-app .ef-option[aria-pressed='true'] { + background: #eef3e9; + border-color: #435d47; + box-shadow: inset 0 0 0 1px #435d47; +} +.ef-option small { + display: block; + margin-top: 2px; + font-size: 11px; + font-weight: 400; + color: #6b7768; +} +.ef-option svg { + flex-shrink: 0; + color: #435d47; +} +.ef-length-form { + display: flex; + align-items: end; + gap: 8px; + margin-top: 14px; +} +.ef-length-form label { + flex: 1; + display: grid; + gap: 4px; + font-size: 11px; + font-weight: 600; + color: #6b7768; +} +.ef-length-form input { + width: 100%; + min-width: 0; + font: inherit; + font-size: 13px; + color: #23382c; + border: 1px solid #dce3d5; + border-radius: 9px; + padding: 9px 10px; +} +.explore-app .ef-length-form button { + font-size: 12px; + padding: 10px 14px; +} +.ef-note { + margin: 12px 0 0; + font-size: 11px; + line-height: 1.5; + color: #6b7768; +} +.ef-menu-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #e3e8df; +} +.explore-app .ef-menu-footer .text-button { + padding: 0; + font-size: 13px; +} +.explore-app .ef-menu-footer button:last-child { + font-size: 13px; + padding: 9px 16px; +} +@media (max-width: 720px) { + .ef { + top: 10px; + left: 10px; + right: 56px; + } + .ef-options.ef-grid-3 { + grid-template-columns: 1fr; + } +} +@media (prefers-reduced-motion: reduce) { + .ef-pill svg { + transition: none; + } +} diff --git a/src/Explore/ExploreFilters.tsx b/src/Explore/ExploreFilters.tsx index 08a2425..7523164 100644 --- a/src/Explore/ExploreFilters.tsx +++ b/src/Explore/ExploreFilters.tsx @@ -24,9 +24,11 @@ const LENGTH_PRESETS: { label: string; hint: string; min: number | null; max: nu const ICONS: Record = { hut: 'M3 11 12 4l9 7M5 9.5V20h14V9.5M10 20v-5h4v5', shelter: 'M3 20 13 5l8 15M2 20h20M9 20l4-6', - viewpoint: 'M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12ZM12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z', + viewpoint: + 'M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12ZM12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z', picnic_site: 'M3 9h18M7 9l-3 10M17 9l3 10M5.5 14h13M12 9V5', - toilets: 'M7 3.5a1.5 1.5 0 1 0 0 .01M17 3.5a1.5 1.5 0 1 0 0 .01M5 21v-6H4l1.5-7h3L10 15H9v6M15 21V8h4v13', + toilets: + 'M7 3.5a1.5 1.5 0 1 0 0 .01M17 3.5a1.5 1.5 0 1 0 0 .01M5 21v-6H4l1.5-7h3L10 15H9v6M15 21V8h4v13', drinking_water: 'M12 3s6 7 6 11a6 6 0 0 1-12 0c0-4 6-11 6-11Z', parking: 'M5 3h14v18H5zM10 17V7h3a3 3 0 0 1 0 6h-3', camp_site: 'M2 20h20M4 20 12 5l8 15M12 20l-3-5M12 20l3-5', @@ -35,15 +37,22 @@ const ICONS: Record = { /** Hva «Nullstill» i hver meny setter tilbake */ const CLEARED: Record> = { - difficulty: { difficulties: [] }, length: { minLength: null, maxLength: null }, routeType: { routeTypes: [] }, facilities: { facilities: [] }, + difficulty: { difficulties: [] }, + length: { minLength: null, maxLength: null }, + routeType: { routeTypes: [] }, + facilities: { facilities: [] }, } const fmt = (value: number) => value.toLocaleString('nb-NO') -function toggle(list: string[], value: string) { return list.includes(value) ? list.filter(item => item !== value) : [...list, value] } +function toggle(list: string[], value: string) { + return list.includes(value) ? list.filter((item) => item !== value) : [...list, value] +} /** «Enkel, Middels» eller «Enkel +2» */ -function summary(values: string[], labelOf: (value: string) => string = value => value) { +function summary(values: string[], labelOf: (value: string) => string = (value) => value) { if (!values.length) return null - return values.length <= 2 ? values.map(labelOf).join(', ') : `${labelOf(values[0])} +${values.length - 1}` + return values.length <= 2 + ? values.map(labelOf).join(', ') + : `${labelOf(values[0])} +${values.length - 1}` } function lengthSummary(min: number | null, max: number | null) { if (min == null && max == null) return null @@ -51,8 +60,10 @@ function lengthSummary(min: number | null, max: number | null) { if (max == null) return `Over ${fmt(min)} km` return `${fmt(min)}–${fmt(max)} km` } -const routeTypeLabel = (value: string) => ROUTE_TYPES.find(type => type.value === value)?.label ?? value -const facilityLabel = (value: string) => placeCategories.find(category => category.value === value)?.label ?? value +const routeTypeLabel = (value: string) => + ROUTE_TYPES.find((type) => type.value === value)?.label ?? value +const facilityLabel = (value: string) => + placeCategories.find((category) => category.value === value)?.label ?? value type Props = { value: RouteFilterValue; onChange: (next: RouteFilterValue) => void } @@ -69,26 +80,50 @@ export default function ExploreFilters({ value, onChange }: Props) { // Lukk menyen ved klikk utenfor eller Escape (samme mønster som profilmenyen i SiteNav) useEffect(() => { if (!open) return - const close = (event: PointerEvent) => { if (!wrapRef.current?.contains(event.target as Node)) setOpen(null) } - const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpen(null); buttonRefs.current[open]?.focus() } } + const close = (event: PointerEvent) => { + if (!wrapRef.current?.contains(event.target as Node)) setOpen(null) + } + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(null) + buttonRefs.current[open]?.focus() + } + } document.addEventListener('pointerdown', close) document.addEventListener('keydown', escape) // Flytt fokus inn i menyen, så tastaturbrukere kommer rett til valgene - const frame = window.requestAnimationFrame(() => menuRef.current?.querySelector('button, input')?.focus()) - return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', escape); window.cancelAnimationFrame(frame) } + const frame = window.requestAnimationFrame(() => + menuRef.current?.querySelector('button, input')?.focus(), + ) + return () => { + document.removeEventListener('pointerdown', close) + document.removeEventListener('keydown', escape) + window.cancelAnimationFrame(frame) + } }, [open]) function toggleMenu(id: MenuId) { - if (open === id) { setOpen(null); return } - const button = buttonRefs.current[id], wrap = wrapRef.current + if (open === id) { + setOpen(null) + return + } + const button = buttonRefs.current[id], + wrap = wrapRef.current if (button && wrap) { const left = button.getBoundingClientRect().left - wrap.getBoundingClientRect().left setAnchor(Math.max(0, Math.min(left, wrap.clientWidth - 340))) } - if (id === 'length') { setMinInput(value.minLength?.toString() ?? ''); setMaxInput(value.maxLength?.toString() ?? '') } + if (id === 'length') { + setMinInput(value.minLength?.toString() ?? '') + setMaxInput(value.maxLength?.toString() ?? '') + } setOpen(id) } - function done() { const current = open; setOpen(null); if (current) buttonRefs.current[current]?.focus() } + function done() { + const current = open + setOpen(null) + if (current) buttonRefs.current[current]?.focus() + } const buttons: { id: MenuId; label: string; active: string | null }[] = [ { id: 'difficulty', label: 'Vanskelighetsgrad', active: summary(value.difficulties) }, @@ -96,70 +131,217 @@ export default function ExploreFilters({ value, onChange }: Props) { { id: 'routeType', label: 'Turtype', active: summary(value.routeTypes, routeTypeLabel) }, { id: 'facilities', label: 'Langs turen', active: summary(value.facilities, facilityLabel) }, ] - const anyActive = buttons.some(button => button.active) - const current = buttons.find(button => button.id === open) - - return
-
- {buttons.map(button => )} - {anyActive && } -
+ const anyActive = buttons.some((button) => button.active) + const current = buttons.find((button) => button.id === open) - {open && current && + )} +
+ ) } diff --git a/src/Explore/ExploreMap.css b/src/Explore/ExploreMap.css index 65496ba..531bcd8 100644 --- a/src/Explore/ExploreMap.css +++ b/src/Explore/ExploreMap.css @@ -1,14 +1,71 @@ /* Stort utforskningskart øverst på Utforsk turer (ExploreMap.tsx) */ /* Kartet går helt ut til kantene av vinduet (bryter ut av main sin padding og maksbredde) og fyller skjermhøyden under toppmenyen. calc(50% - 50vw) flytter kantene ut til vinduets kant. */ -.explore-app { overflow-x: clip; } -.explore-map { position: relative; height: clamp(480px, calc(100svh - 86px), 900px); margin: -24px calc(50% - 50vw) 32px; overflow: hidden; background: #e4eadb; border-bottom: 1px solid #e3e8df; } -.explore-map-canvas-wrap, .explore-map-canvas { position: absolute; inset: 0; } -.explore-map-error { position: absolute; z-index: 3; bottom: 16px; left: 50%; transform: translateX(-50%); max-width: 420px; margin: 0; padding: 10px 14px; background: #fff; border-radius: 10px; font-size: 13px; } +.explore-app { + overflow-x: clip; +} +.explore-map { + position: relative; + height: clamp(480px, calc(100svh - 86px), 900px); + margin: -24px calc(50% - 50vw) 32px; + overflow: hidden; + background: #e4eadb; + border-bottom: 1px solid #e3e8df; +} +.explore-map-canvas-wrap, +.explore-map-canvas { + position: absolute; + inset: 0; +} +.explore-map-error { + position: absolute; + z-index: 3; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + max-width: 420px; + margin: 0; + padding: 10px 14px; + background: #fff; + border-radius: 10px; + font-size: 13px; +} /* Hindrer at de generelle knappestilene i .explore-app gjør kartets zoomknapper grønne */ -.explore-app .explore-map .maplibregl-ctrl-group button { background-color: #fff; color: #23382c; padding: 0; border-radius: 0; width: 36px; height: 36px; transition: none; } -.explore-app .explore-map .maplibregl-ctrl-group button:hover:not(:disabled) { background-color: #edf2e9; } -.explore-app .explore-map .maplibregl-ctrl-group { border-radius: 10px; overflow: hidden; box-shadow: 0 4px 16px #23382c22; } -.explore-map .maplibregl-ctrl-top-right { margin: 6px; } -@media (max-width: 1050px) { .explore-map { margin-top: -20px; } } -@media (max-width: 720px) { .explore-map { margin-top: -16px; height: clamp(420px, calc(100svh - 130px), 700px); } .explore-map .maplibregl-ctrl-bottom-right { bottom: 55%; } .explore-map-error { bottom: auto; top: 10px; } } +.explore-app .explore-map .maplibregl-ctrl-group button { + background-color: #fff; + color: #23382c; + padding: 0; + border-radius: 0; + width: 36px; + height: 36px; + transition: none; +} +.explore-app .explore-map .maplibregl-ctrl-group button:hover:not(:disabled) { + background-color: #edf2e9; +} +.explore-app .explore-map .maplibregl-ctrl-group { + border-radius: 10px; + overflow: hidden; + box-shadow: 0 4px 16px #23382c22; +} +.explore-map .maplibregl-ctrl-top-right { + margin: 6px; +} +@media (max-width: 1050px) { + .explore-map { + margin-top: -20px; + } +} +@media (max-width: 720px) { + .explore-map { + margin-top: -16px; + height: clamp(420px, calc(100svh - 130px), 700px); + } + .explore-map .maplibregl-ctrl-bottom-right { + bottom: 55%; + } + .explore-map-error { + bottom: auto; + top: 10px; + } +} diff --git a/src/Explore/ExploreMap.tsx b/src/Explore/ExploreMap.tsx index c6eda24..5486e52 100644 --- a/src/Explore/ExploreMap.tsx +++ b/src/Explore/ExploreMap.tsx @@ -16,10 +16,17 @@ import './ExploreMap.css' maplibregl.setWorkerUrl(workerUrl) /** Et område kartet skal fly til. key økes for hver ny forespørsel, så samme område kan velges flere ganger. */ -export type MapFocus = { bounds: Bounds; key: number; /** Ikke filtrer lista på det nye utsnittet («Vis hele Norge») */ clearFilter?: boolean } +export type MapFocus = { + bounds: Bounds + key: number + /** Ikke filtrer lista på det nye utsnittet («Vis hele Norge») */ clearFilter?: boolean +} /** Hvor langt brukeren kan dra kartet bort fra Norge */ -const MAX_BOUNDS: maplibregl.LngLatBoundsLike = [[-10, 54], [45, 81.5]] +const MAX_BOUNDS: maplibregl.LngLatBoundsLike = [ + [-10, 54], + [45, 81.5], +] /** Venter litt etter at kartet stopper før lista oppdateres, så vi ikke sender en forespørsel per bevegelse */ const BOUNDS_DELAY_MS = 400 @@ -32,7 +39,8 @@ const MAP_STYLE: StyleSpecification = { tiles: ['https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png'], tileSize: 256, maxzoom: 18, - attribution: '© Kartverket · © OpenStreetMap-bidragsytere', + attribution: + '© Kartverket · © OpenStreetMap-bidragsytere', }, }, layers: [{ id: 'topo', type: 'raster', source: 'topo' }], @@ -41,7 +49,10 @@ const MAP_STYLE: StyleSpecification = { const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] } const round = (value: number) => Math.round(value * 10000) / 10000 const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches -const toLngLat = (b: Bounds): maplibregl.LngLatBoundsLike => [[b.west, b.south], [b.east, b.north]] +const toLngLat = (b: Bounds): maplibregl.LngLatBoundsLike => [ + [b.west, b.south], + [b.east, b.north], +] /** Søkepanelet ligger oppå kartet (til venstre på stor skjerm, nederst på mobil). * Når vi zoomer til et område eller en tur, holder vi av plass til panelet så det ikke dekker det. */ @@ -68,7 +79,9 @@ export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: P const [error, setError] = useState('') // Kartets hendelser bruker alltid siste versjon av callbacken, uten at kartet må lages på nytt const onBoundsChangeRef = useRef(onBoundsChange) - useEffect(() => { onBoundsChangeRef.current = onBoundsChange }) + useEffect(() => { + onBoundsChangeRef.current = onBoundsChange + }) // Fokuset kartet ble laget med, og sist fokus vi har flydd til const lastFocus = useRef(focus) @@ -99,7 +112,9 @@ export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: P } catch { // Nettleseren støtter ikke WebGL. Dette rapporterer feil fra et eksternt system, ikke avledet state. // eslint-disable-next-line react-hooks/set-state-in-effect - setError('Kartet kunne ikke åpnes i denne nettleseren. Du kan fortsatt søke og filtrere i lista under.') + setError( + 'Kartet kunne ikke åpnes i denne nettleseren. Du kan fortsatt søke og filtrere i lista under.', + ) return } map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right') @@ -107,8 +122,18 @@ export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: P map.on('load', () => { map.addSource('selected-route', { type: 'geojson', data: EMPTY }) - map.addLayer({ id: 'selected-route-outline', type: 'line', source: 'selected-route', paint: { 'line-color': '#fff', 'line-width': 8 } }) - map.addLayer({ id: 'selected-route-line', type: 'line', source: 'selected-route', paint: { 'line-color': '#126b51', 'line-width': 4 } }) + map.addLayer({ + id: 'selected-route-outline', + type: 'line', + source: 'selected-route', + paint: { 'line-color': '#fff', 'line-width': 8 }, + }) + map.addLayer({ + id: 'selected-route-line', + type: 'line', + source: 'selected-route', + paint: { 'line-color': '#126b51', 'line-width': 4 }, + }) setLoaded(true) }) map.on('error', () => setError('En del av kartet kunne ikke lastes. Lista fungerer fortsatt.')) @@ -119,17 +144,30 @@ export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: P let timer: number | undefined const report = (byUser: boolean) => { const b = map.getBounds() - onBoundsChangeRef.current({ west: round(b.getWest()), south: round(b.getSouth()), east: round(b.getEast()), north: round(b.getNorth()) }, byUser) + onBoundsChangeRef.current( + { + west: round(b.getWest()), + south: round(b.getSouth()), + east: round(b.getEast()), + north: round(b.getNorth()), + }, + byUser, + ) } map.once('load', () => report(false)) - map.on('moveend', event => { + map.on('moveend', (event) => { const { programmatic, skipReport } = event as { programmatic?: boolean; skipReport?: boolean } window.clearTimeout(timer) if (skipReport) return timer = window.setTimeout(() => report(!programmatic), programmatic ? 0 : BOUNDS_DELAY_MS) }) - return () => { window.clearTimeout(timer); map.remove(); mapRef.current = null; setLoaded(false) } + return () => { + window.clearTimeout(timer) + map.remove() + mapRef.current = null + setLoaded(false) + } }, []) // 2. Fly til nytt fokus: brukerens sted fra profilen, et sted fra søket, eller hele Norge @@ -137,8 +175,11 @@ export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: P const map = mapRef.current if (!map || !container.current || focus.key === lastFocus.current.key) return lastFocus.current = focus - map.fitBounds(toLngLat(focus.bounds), { padding: panelPadding(container.current), duration: reducedMotion() ? 0 : 900 }, - { programmatic: true, skipReport: focus.clearFilter }) + map.fitBounds( + toLngLat(focus.bounds), + { padding: panelPadding(container.current), duration: reducedMotion() ? 0 : 900 }, + { programmatic: true, skipReport: focus.clearFilter }, + ) }, [focus]) // 3. Tegn valgt tur og zoom til den @@ -146,20 +187,40 @@ export default function ExploreMap({ focus, onBoundsChange, selectedGeojson }: P const map = mapRef.current if (!map || !loaded || !container.current) return const source = map.getSource('selected-route') as GeoJSONSource | undefined - if (!selectedGeojson) { source?.setData(EMPTY); return } + if (!selectedGeojson) { + source?.setData(EMPTY) + return + } try { const geometry = JSON.parse(selectedGeojson) as LineString | MultiLineString const feature: Feature = { type: 'Feature', properties: {}, geometry } source?.setData(feature) - const points = geometry.type === 'LineString' ? geometry.coordinates : geometry.coordinates.flat() + const points = + geometry.type === 'LineString' ? geometry.coordinates : geometry.coordinates.flat() const bounds = new maplibregl.LngLatBounds() points.forEach(([lng, lat]) => bounds.extend([lng, lat])) - map.fitBounds(bounds, { padding: panelPadding(container.current), maxZoom: 14, duration: reducedMotion() ? 0 : 900 }, { programmatic: true }) - } catch { source?.setData(EMPTY) } + map.fitBounds( + bounds, + { + padding: panelPadding(container.current), + maxZoom: 14, + duration: reducedMotion() ? 0 : 900, + }, + { programmatic: true }, + ) + } catch { + source?.setData(EMPTY) + } }, [selectedGeojson, loaded]) - return
-
- {error &&

{error}

} -
+ return ( +
+
+ {error && ( +

+ {error} +

+ )} +
+ ) } diff --git a/src/Explore/ExploreSearchPanel.css b/src/Explore/ExploreSearchPanel.css index 986c31f..9e6ed53 100644 --- a/src/Explore/ExploreSearchPanel.css +++ b/src/Explore/ExploreSearchPanel.css @@ -1,52 +1,293 @@ /* Søkepanelet oppå kartet på Utforsk turer (ExploreSearchPanel.tsx). Stor skjerm: kort øverst til venstre. Mobil: panel nederst, med kartet synlig over. */ -.esp { position: absolute; z-index: 2; top: 16px; left: 16px; width: min(400px, calc(100% - 100px)); max-height: calc(100% - 32px); display: flex; flex-direction: column; background: #fff; border-radius: 14px; box-shadow: 0 8px 30px #23382c2e; overflow: hidden; font-family: 'Avenir Next', system-ui, sans-serif; color: #23382c; } -.esp-search { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 8px 14px; border-bottom: 1px solid #e3e8df; } -.esp-search:focus-within { box-shadow: inset 0 0 0 2px #435d47; } -.esp-search svg { flex-shrink: 0; color: #667c64; } -.esp-search input { flex: 1; min-width: 120px; border: 0; background: transparent; font: inherit; font-size: 15px; padding: 10px 0; color: inherit; } -.esp-search input:focus-visible { outline: none; } -.esp-chip { display: inline-flex; align-items: center; gap: 2px; background: #e9efe4; color: #435d47; border-radius: 999px; padding: 3px 3px 3px 11px; font-size: 12px; font-weight: 600; } -.explore-app .esp-chip button { background: transparent; color: inherit; padding: 2px 7px; font-size: 16px; line-height: 1; border-radius: 999px; } -.explore-app .esp-chip button:hover:not(:disabled) { background: #d8e3d1; } -.esp-body { overflow-y: auto; overscroll-behavior: contain; padding: 14px 16px 16px; } -.esp-title { font-size: 18px; letter-spacing: -.3px; margin: 0 0 4px; font-weight: 600; } -.esp-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0 0 12px; font-size: 12px; color: #6b7768; line-height: 1.5; } -.explore-app .esp .text-button { padding: 0; font-size: 12px; text-decoration: underline; } -.esp-group { margin: 14px 0 8px; font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: #6b7768; font-weight: 600; } -.esp-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } -.explore-app .esp-item { width: 100%; display: flex; gap: 12px; align-items: flex-start; text-align: left; background: #fff; color: #23382c; border: 1px solid #e3e8df; border-radius: 10px; padding: 12px 14px; font-weight: 400; } -.explore-app .esp-item:hover:not(:disabled) { background: #f1f5ee; border-color: #a2b398; } -.explore-app .esp-item[aria-pressed=true] { background: #eef3e9; border-color: #435d47; } -.esp-item strong { display: block; font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } -.esp-item-meta { display: block; margin-top: 3px; font-size: 12px; color: #6b7768; } -.esp-item-icon { font-size: 15px; line-height: 1.3; color: #677c60; } -.esp-pager { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; font-size: 12px; color: #6b7768; } -.explore-app .esp-pager button { background: #fff; color: #435d47; border: 1px solid #e3e8df; font-size: 12px; padding: 8px 12px; } -.explore-app .esp-pager button:hover:not(:disabled) { background: #edf2e9; } -.explore-app .esp .esp-back { font-size: 13px; text-decoration: none; margin-bottom: 8px; } -.esp-eyebrow { margin: 8px 0 0; font-size: 10px; letter-spacing: 1.8px; color: #6b7768; } -.esp-route h2 { margin: 4px 0 4px; font-size: 24px; letter-spacing: -.6px; line-height: 1.15; overflow-wrap: anywhere; } -.esp-variant label { display: block; margin: 4px 0 6px; font-size: 11px; font-weight: 600; color: #6b7768; } -.esp-variant select { width: 100%; } -.esp-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 16px 0; } -.esp-stats div { background: #f4f6f1; border-radius: 10px; padding: 10px; min-width: 0; } -.esp-stats strong { display: block; font-size: 14px; overflow-wrap: anywhere; } -.esp-stats span { display: block; margin-top: 2px; font-size: 11px; color: #6b7768; } -.esp-description { font-size: 13px; line-height: 1.6; color: #3b4d40; margin: 0 0 12px; } -.esp-notice { font-size: 12px; line-height: 1.5; background: #f4f0e4; border-radius: 9px; padding: 10px 12px; margin: 12px 0; } -.esp-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-top: 16px; } -.explore-app .esp-actions > button:first-child { font-size: 13px; } -.esp-error p { margin: 0 0 10px; font-size: 13px; } +.esp { + position: absolute; + z-index: 2; + top: 16px; + left: 16px; + width: min(400px, calc(100% - 100px)); + max-height: calc(100% - 32px); + display: flex; + flex-direction: column; + background: #fff; + border-radius: 14px; + box-shadow: 0 8px 30px #23382c2e; + overflow: hidden; + font-family: 'Avenir Next', system-ui, sans-serif; + color: #23382c; +} +.esp-search { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 10px; + padding: 8px 14px; + border-bottom: 1px solid #e3e8df; +} +.esp-search:focus-within { + box-shadow: inset 0 0 0 2px #435d47; +} +.esp-search svg { + flex-shrink: 0; + color: #667c64; +} +.esp-search input { + flex: 1; + min-width: 120px; + border: 0; + background: transparent; + font: inherit; + font-size: 15px; + padding: 10px 0; + color: inherit; +} +.esp-search input:focus-visible { + outline: none; +} +.esp-chip { + display: inline-flex; + align-items: center; + gap: 2px; + background: #e9efe4; + color: #435d47; + border-radius: 999px; + padding: 3px 3px 3px 11px; + font-size: 12px; + font-weight: 600; +} +.explore-app .esp-chip button { + background: transparent; + color: inherit; + padding: 2px 7px; + font-size: 16px; + line-height: 1; + border-radius: 999px; +} +.explore-app .esp-chip button:hover:not(:disabled) { + background: #d8e3d1; +} +.esp-body { + overflow-y: auto; + overscroll-behavior: contain; + padding: 14px 16px 16px; +} +.esp-title { + font-size: 18px; + letter-spacing: -0.3px; + margin: 0 0 4px; + font-weight: 600; +} +.esp-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + margin: 0 0 12px; + font-size: 12px; + color: #6b7768; + line-height: 1.5; +} +.explore-app .esp .text-button { + padding: 0; + font-size: 12px; + text-decoration: underline; +} +.esp-group { + margin: 14px 0 8px; + font-size: 10px; + letter-spacing: 1.6px; + text-transform: uppercase; + color: #6b7768; + font-weight: 600; +} +.esp-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} +.explore-app .esp-item { + width: 100%; + display: flex; + gap: 12px; + align-items: flex-start; + text-align: left; + background: #fff; + color: #23382c; + border: 1px solid #e3e8df; + border-radius: 10px; + padding: 12px 14px; + font-weight: 400; +} +.explore-app .esp-item:hover:not(:disabled) { + background: #f1f5ee; + border-color: #a2b398; +} +.explore-app .esp-item[aria-pressed='true'] { + background: #eef3e9; + border-color: #435d47; +} +.esp-item strong { + display: block; + font-size: 14px; + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; +} +.esp-item-meta { + display: block; + margin-top: 3px; + font-size: 12px; + color: #6b7768; +} +.esp-item-icon { + font-size: 15px; + line-height: 1.3; + color: #677c60; +} +.esp-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 12px; + font-size: 12px; + color: #6b7768; +} +.explore-app .esp-pager button { + background: #fff; + color: #435d47; + border: 1px solid #e3e8df; + font-size: 12px; + padding: 8px 12px; +} +.explore-app .esp-pager button:hover:not(:disabled) { + background: #edf2e9; +} +.explore-app .esp .esp-back { + font-size: 13px; + text-decoration: none; + margin-bottom: 8px; +} +.esp-eyebrow { + margin: 8px 0 0; + font-size: 10px; + letter-spacing: 1.8px; + color: #6b7768; +} +.esp-route h2 { + margin: 4px 0 4px; + font-size: 24px; + letter-spacing: -0.6px; + line-height: 1.15; + overflow-wrap: anywhere; +} +.esp-variant label { + display: block; + margin: 4px 0 6px; + font-size: 11px; + font-weight: 600; + color: #6b7768; +} +.esp-variant select { + width: 100%; +} +.esp-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 16px 0; +} +.esp-stats div { + background: #f4f6f1; + border-radius: 10px; + padding: 10px; + min-width: 0; +} +.esp-stats strong { + display: block; + font-size: 14px; + overflow-wrap: anywhere; +} +.esp-stats span { + display: block; + margin-top: 2px; + font-size: 11px; + color: #6b7768; +} +.esp-description { + font-size: 13px; + line-height: 1.6; + color: #3b4d40; + margin: 0 0 12px; +} +.esp-notice { + font-size: 12px; + line-height: 1.5; + background: #f4f0e4; + border-radius: 9px; + padding: 10px 12px; + margin: 12px 0; +} +.esp-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + margin-top: 16px; +} +.explore-app .esp-actions > button:first-child { + font-size: 13px; +} +.esp-error p { + margin: 0 0 10px; + font-size: 13px; +} @media (max-width: 720px) { - .esp { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 55%; border-radius: 16px 16px 0 0; } - .esp-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .esp { + top: auto; + left: 0; + right: 0; + bottom: 0; + width: auto; + max-height: 55%; + border-radius: 16px 16px 0 0; + } + .esp-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } /* Sortering over turlista. Overstyrer de store standardstilene for select i .explore-app. */ -.esp-sort { display: flex; align-items: center; gap: 8px; margin: -4px 0 12px; font-size: 12px; font-weight: 600; color: #6b7768; } -.explore-app .esp-sort select { flex: 1; min-height: 34px; padding: 6px 32px 6px 10px !important; font-size: 12px !important; background-position: right 10px center !important; } +.esp-sort { + display: flex; + align-items: center; + gap: 8px; + margin: -4px 0 12px; + font-size: 12px; + font-weight: 600; + color: #6b7768; +} +.explore-app .esp-sort select { + flex: 1; + min-height: 34px; + padding: 6px 32px 6px 10px !important; + font-size: 12px !important; + background-position: right 10px center !important; +} /* Høydeprofilen (ElevationProfile.tsx) inne i turvisningen i panelet */ -.esp .elevation-panel { margin: 18px 0 0; padding: 14px; border-radius: 10px; } -.esp .elevation-panel h3 { font-size: 14px; margin-bottom: 6px; } -.esp .elevation-panel p { font-size: 12px; } -.esp .elevation-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.esp .elevation-panel { + margin: 18px 0 0; + padding: 14px; + border-radius: 10px; +} +.esp .elevation-panel h3 { + font-size: 14px; + margin-bottom: 6px; +} +.esp .elevation-panel p { + font-size: 12px; +} +.esp .elevation-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} diff --git a/src/Explore/ExploreSearchPanel.tsx b/src/Explore/ExploreSearchPanel.tsx index 52f2243..79db832 100644 --- a/src/Explore/ExploreSearchPanel.tsx +++ b/src/Explore/ExploreSearchPanel.tsx @@ -6,12 +6,26 @@ import { useEffect, useRef, useState } from 'react' import { graphql } from '../graphql' import RouteDetails from './RouteDetails' -import { PAGE_SIZE, SORT_OPTIONS, filterVariables, lengthText, type Route, type RouteFilterValue, type RouteGroup, type RouteSort, type RouteSummary, type SelectedRoute } from './routeTypes' +import { + PAGE_SIZE, + SORT_OPTIONS, + filterVariables, + lengthText, + type Route, + type RouteFilterValue, + type RouteGroup, + type RouteSort, + type RouteSummary, + type SelectedRoute, +} from './routeTypes' import './ExploreSearchPanel.css' /** En kommune fra søket. bbox er [vest, sør, øst, nord], brukt til å fly kartet dit. */ export type PlaceHit = { code: string; name: string; routeCount: number; bbox: number[] | null } -type SearchData = { routes: { total: number; items: RouteGroup[] }; searchMunicipalities: PlaceHit[] } +type SearchData = { + routes: { total: number; items: RouteGroup[] } + searchMunicipalities: PlaceHit[] +} /** Hvor lenge vi venter etter siste tastetrykk før vi søker (debouncing, sparer requests) */ const SEARCH_DELAY_MS = 300 @@ -55,26 +69,74 @@ type Props = { } /** Ett turkort i lister og søk */ -function RouteItem({ group, active, onChoose }: { group: RouteGroup; active: boolean; onChoose: () => void }) { +function RouteItem({ + group, + active, + onChoose, +}: { + group: RouteGroup + active: boolean + onChoose: () => void +}) { const first = group.variants[0] - const meta = [lengthText(group), group.variantCount > 1 ? `${group.variantCount} varianter` : null, first?.difficulty, - group.favoriteCount ? `♥ ${group.favoriteCount}` : null].filter(Boolean).join(' · ') - return
  • + const meta = [ + lengthText(group), + group.variantCount > 1 ? `${group.variantCount} varianter` : null, + first?.difficulty, + group.favoriteCount ? `♥ ${group.favoriteCount}` : null, + ] + .filter(Boolean) + .join(' · ') + return ( +
  • + +
  • + ) } export default function ExploreSearchPanel(props: Props) { - const { filters, sort, onSortChange, items, total, listStatus, page, onPageChange, filtered, onShowAll, place, onClearPlace, onChoosePlace, onChooseRoute, - selected, route, routeVariants, status, onRetry, onBack, authenticated, onSaveRoute } = props + const { + filters, + sort, + onSortChange, + items, + total, + listStatus, + page, + onPageChange, + filtered, + onShowAll, + place, + onClearPlace, + onChoosePlace, + onChooseRoute, + selected, + route, + routeVariants, + status, + onRetry, + onBack, + authenticated, + onSaveRoute, + } = props const inputRef = useRef(null) const backRef = useRef(null) // --- Søk med debouncing. Utdaterte forespørsler avbrytes. --- const [input, setInput] = useState('') // Svaret lagres sammen med søket det hører til, så status kan avledes under rendering (som på forsiden) - const [searchResult, setSearchResult] = useState<{ search: string; data: SearchData | null } | null>(null) + const [searchResult, setSearchResult] = useState<{ + search: string + data: SearchData | null + } | null>(null) // Filtrene gjøres om til tekst, så søket bare kjøres på nytt når de faktisk endres const filterKey = JSON.stringify(filterVariables(filters)) useEffect(() => { @@ -82,19 +144,41 @@ export default function ExploreSearchPanel(props: Props) { if (search.length < 2) return const controller = new AbortController() const timer = window.setTimeout(() => { - graphql(SEARCH_QUERY, { search, limit: 6, ...JSON.parse(filterKey) }, controller.signal) - .then(data => setSearchResult({ search: search + filterKey, data })) - .catch(() => { if (!controller.signal.aborted) setSearchResult({ search: search + filterKey, data: null }) }) + graphql( + SEARCH_QUERY, + { search, limit: 6, ...JSON.parse(filterKey) }, + controller.signal, + ) + .then((data) => setSearchResult({ search: search + filterKey, data })) + .catch(() => { + if (!controller.signal.aborted) + setSearchResult({ search: search + filterKey, data: null }) + }) }, SEARCH_DELAY_MS) - return () => { window.clearTimeout(timer); controller.abort() } + return () => { + window.clearTimeout(timer) + controller.abort() + } }, [input, filterKey]) const trimmed = input.trim() - const searchStatus = trimmed.length < 2 ? 'idle' : searchResult?.search !== trimmed + filterKey ? 'loading' : searchResult.data ? 'ready' : 'error' + const searchStatus = + trimmed.length < 2 + ? 'idle' + : searchResult?.search !== trimmed + filterKey + ? 'loading' + : searchResult.data + ? 'ready' + : 'error' const hits = searchStatus === 'ready' ? searchResult!.data! : null const placeCount = hits?.searchMunicipalities.length ?? 0 const routeTotal = hits?.routes.total ?? 0 - const hitSummary = [placeCount && `${placeCount} ${placeCount === 1 ? 'sted' : 'steder'}`, routeTotal && `${routeTotal.toLocaleString('nb-NO')} turer`] - .filter(Boolean).join(' og ') || 'Ingen steder eller turer passer søket.' + const hitSummary = + [ + placeCount && `${placeCount} ${placeCount === 1 ? 'sted' : 'steder'}`, + routeTotal && `${routeTotal.toLocaleString('nb-NO')} turer`, + ] + .filter(Boolean) + .join(' og ') || 'Ingen steder eller turer passer søket.' // Søk går foran valgt tur, så man kan søke videre mens en tur vises const mode = trimmed.length >= 2 ? 'search' : selected ? 'route' : 'list' @@ -112,74 +196,211 @@ export default function ExploreSearchPanel(props: Props) { } const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)) - const listTitle = place ? `Turer i ${place.name}` : filtered ? 'Turer i kartutsnittet' : 'Turer i hele Norge' - - return
    -
    event.preventDefault()}> - - - {place && {place.name}} - setInput(event.target.value)} - onKeyDown={event => { if (event.key === 'Escape') setInput('') }} - aria-describedby="esp-status" /> -
    + const listTitle = place + ? `Turer i ${place.name}` + : filtered + ? 'Turer i kartutsnittet' + : 'Turer i hele Norge' -
    - {mode === 'search' && <> -

    - {searchStatus === 'loading' && 'Søker …'} - {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'} - {hits && hitSummary} -

    - {placeCount > 0 && <> -

    Steder

    -
      {hits!.searchMunicipalities.map(hit =>
    • - -
    • )}
    - } - {hits && hits.routes.items.length > 0 && <> -

    Turer{routeTotal > hits.routes.items.length ? ` (viser ${hits.routes.items.length} av ${routeTotal.toLocaleString('nb-NO')})` : ''}

    -
      {hits.routes.items.map(group => - chooseRoute(group.variants[0])} />)}
    - } - } + + )} + setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') setInput('') + }} + aria-describedby="esp-status" + /> + - {mode === 'route' && <> - - {status === 'loading' &&

    Henter turen …

    } - {status === 'error' &&

    Kunne ikke hente turen.

    } - {status === 'empty' &&

    Denne turen har ingen kartgeometri.

    } - {status === 'ready' && route && } - } +
    + {mode === 'search' && ( + <> +

    + {searchStatus === 'loading' && 'Søker …'} + {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'} + {hits && hitSummary} +

    + {placeCount > 0 && ( + <> +

    Steder

    +
      + {hits!.searchMunicipalities.map((hit) => ( +
    • + +
    • + ))} +
    + + )} + {hits && hits.routes.items.length > 0 && ( + <> +

    + Turer + {routeTotal > hits.routes.items.length + ? ` (viser ${hits.routes.items.length} av ${routeTotal.toLocaleString('nb-NO')})` + : ''} +

    +
      + {hits.routes.items.map((group) => ( + chooseRoute(group.variants[0])} + /> + ))} +
    + + )} + + )} - {mode === 'list' && <> -

    {listTitle}

    -

    - {listStatus === 'loading' && 'Henter turer …'} - {listStatus === 'error' && 'Kunne ikke hente turene. Prøv å flytte kartet igjen.'} - {listStatus === 'ready' && (total ? `${total.toLocaleString('nb-NO')} turer` : 'Ingen turer her. Flytt eller zoom ut kartet.')} - {filtered && } -

    - - {listStatus === 'ready' && items.length > 0 && <> -
      {items.map(group => - selected?.name === item.name && selected?.number === item.number)} onChoose={() => chooseRoute(group.variants[0])} />)}
    - {pages > 1 && } - } - } -
    -
    + {mode === 'route' && ( + <> + + {status === 'loading' && ( +

    + Henter turen … +

    + )} + {status === 'error' && ( +
    +

    Kunne ikke hente turen.

    + +
    + )} + {status === 'empty' && ( +

    + Denne turen har ingen kartgeometri. +

    + )} + {status === 'ready' && route && ( + + )} + + )} + + {mode === 'list' && ( + <> +

    {listTitle}

    +

    + {listStatus === 'loading' && 'Henter turer …'} + {listStatus === 'error' && 'Kunne ikke hente turene. Prøv å flytte kartet igjen.'} + {listStatus === 'ready' && + (total + ? `${total.toLocaleString('nb-NO')} turer` + : 'Ingen turer her. Flytt eller zoom ut kartet.')} + {filtered && ( + + )} +

    + + {listStatus === 'ready' && items.length > 0 && ( + <> +
      + {items.map((group) => ( + selected?.name === item.name && selected?.number === item.number, + )} + onChoose={() => chooseRoute(group.variants[0])} + /> + ))} +
    + {pages > 1 && ( + + )} + + )} + + )} +
    + + ) } diff --git a/src/Explore/RouteDetails.tsx b/src/Explore/RouteDetails.tsx index c738383..5112392 100644 --- a/src/Explore/RouteDetails.tsx +++ b/src/Explore/RouteDetails.tsx @@ -12,36 +12,76 @@ type Props = { onSaveRoute: () => void } -export default function RouteDetails({ route, variants, onChooseVariant, authenticated, onSaveRoute }: Props) { - const subtitle = route.nameQuality === 'derived' ? variantLabel(route.number) - : route.name === 'Bymarka' ? 'Hele stinettet i Bymarka' : `Rutenummer ${route.number}` - return
    -

    Viser {route.name} i kartet

    -

    FOTTUR

    -

    {route.name}

    -

    {subtitle}

    - {variants.length > 1 &&
    - - -
    } -
    -
    {km(route.lengthMeters)} kmLengde
    -
    {route.difficulty || 'Ikke gradert'}Vanskelighetsgrad
    -
    {route.segmentCount}{route.connected ? 'Sammenhengende' : 'Adskilte deler'}
    -
    -

    {route.description || 'Ingen turbeskrivelse er oppgitt i kildedataene.'}

    - {route.maintainer &&

    Vedlikeholdes av {route.maintainer}

    } -

    {route.number === 'SAMLET_RUNDE' - ? 'Denne runden er beregnet fra registrerte rutevarianter med samme navn, og er ikke et eget registrert turforslag.' - : 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen.'}

    -
    - -
    - -
    +export default function RouteDetails({ + route, + variants, + onChooseVariant, + authenticated, + onSaveRoute, +}: Props) { + const subtitle = + route.nameQuality === 'derived' + ? variantLabel(route.number) + : route.name === 'Bymarka' + ? 'Hele stinettet i Bymarka' + : `Rutenummer ${route.number}` + return ( +
    +

    + Viser {route.name} i kartet +

    +

    FOTTUR

    +

    {route.name}

    +

    {subtitle}

    + {variants.length > 1 && ( +
    + + +
    + )} +
    +
    + {km(route.lengthMeters)} km + Lengde +
    +
    + {route.difficulty || 'Ikke gradert'} + Vanskelighetsgrad +
    +
    + {route.segmentCount} + {route.connected ? 'Sammenhengende' : 'Adskilte deler'} +
    +
    +

    + {route.description || 'Ingen turbeskrivelse er oppgitt i kildedataene.'} +

    + {route.maintainer &&

    Vedlikeholdes av {route.maintainer}

    } +

    + {route.number === 'SAMLET_RUNDE' + ? 'Denne runden er beregnet fra registrerte rutevarianter med samme navn, og er ikke et eget registrert turforslag.' + : 'Linjen viser det som er registrert i datasettet. Vi har ikke bekreftet at den dekker hele turen.'} +

    +
    + +
    + +
    + ) } diff --git a/src/Explore/routeTypes.ts b/src/Explore/routeTypes.ts index 3cebe80..6baf843 100644 --- a/src/Explore/routeTypes.ts +++ b/src/Explore/routeTypes.ts @@ -3,14 +3,41 @@ /** En tur slik route-spørringen returnerer den, med geometri */ export type Route = { - id: string; name: string; number: string; lengthMeters: number; segmentCount: number - connected: boolean; maintainer: string | null; difficulty: string | null - routeType: string | null; nameQuality: string; duplicateCount: number; variantCount: number - description: string | null; geojson: string + id: string + name: string + number: string + lengthMeters: number + segmentCount: number + connected: boolean + maintainer: string | null + difficulty: string | null + routeType: string | null + nameQuality: string + duplicateCount: number + variantCount: number + description: string | null + geojson: string +} +export type RouteSummary = { + id: string + name: string + number: string + lengthMeters: number + difficulty: string | null + routeType: string | null + duplicateCount: number + variantCount: number } -export type RouteSummary = { id: string; name: string; number: string; lengthMeters: number; difficulty: string | null; routeType: string | null; duplicateCount: number; variantCount: number } /** Turer med samme navn samles i én gruppe i lister og søk */ -export type RouteGroup = { key: string; name: string; variantCount: number; minLengthMeters: number; maxLengthMeters: number; favoriteCount?: number; variants: RouteSummary[] } +export type RouteGroup = { + key: string + name: string + variantCount: number + minLengthMeters: number + maxLengthMeters: number + favoriteCount?: number + variants: RouteSummary[] +} /** Antall turer per side i lista */ export const PAGE_SIZE = 12 @@ -29,7 +56,8 @@ export function variantLabel(number: string) { } /** Meter til kilometer med én desimal, norsk format */ -export const km = (meters: number) => (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) +export const km = (meters: number) => + (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) /** «5,8 km», eller «3,2–7,5 km» når variantene har ulik lengde */ export function lengthText(group: RouteGroup) { @@ -48,7 +76,13 @@ export type RouteFilterValue = { minLength: number | null maxLength: number | null } -export const EMPTY_FILTERS: RouteFilterValue = { difficulties: [], routeTypes: [], facilities: [], minLength: null, maxLength: null } +export const EMPTY_FILTERS: RouteFilterValue = { + difficulties: [], + routeTypes: [], + facilities: [], + minLength: null, + maxLength: null, +} /** Variablene filtrene gir i routes-spørringen */ export function filterVariables(filters: RouteFilterValue) { diff --git a/src/Fjellomrade.css b/src/Fjellomrade.css index b9b653d..bcd1272 100644 --- a/src/Fjellomrade.css +++ b/src/Fjellomrade.css @@ -12,128 +12,639 @@ --sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --shadow: 0 12px 36px -14px rgba(35, 58, 48, 0.45); } -body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; } -.fo-page *, .fo-mapview * { box-sizing: border-box; } -.fo-page a, .fo-mapview a { color: inherit; text-decoration: none; } -.fo-page button, .fo-page input, .fo-mapview button { font: inherit; color: inherit; } -.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } -.fo-kicker { display: block; margin: 0 0 6px; font-size: 11px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--copper); } -.fo-note { margin: 10px 0; font-size: 13px; color: var(--muted); } -.fo-error { margin: 0 0 12px; padding: 12px 14px; border-radius: 12px; background: #f4f0e4; font-size: 14px; } -.fo-dot { width: 12px; height: 12px; flex-shrink: 0; border-radius: 50%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; +} +.fo-page *, +.fo-mapview * { + box-sizing: border-box; +} +.fo-page a, +.fo-mapview a { + color: inherit; + text-decoration: none; +} +.fo-page button, +.fo-page input, +.fo-mapview button { + font: inherit; + color: inherit; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} +.fo-kicker { + display: block; + margin: 0 0 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--copper); +} +.fo-note { + margin: 10px 0; + font-size: 13px; + color: var(--muted); +} +.fo-error { + margin: 0 0 12px; + padding: 12px 14px; + border-radius: 12px; + background: #f4f0e4; + font-size: 14px; +} +.fo-dot { + width: 12px; + height: 12px; + flex-shrink: 0; + border-radius: 50%; +} /* ── Oversikten ── */ -.fo-page { --gutter: clamp(16px, 4vw, 56px); } -.fo-top-bar { display: flex; align-items: center; gap: 28px; padding: 18px var(--gutter); border-bottom: 1px solid var(--line); font-size: 14px; color: var(--muted); } -.fo-brand { margin-right: auto; font-family: var(--serif); font-size: 24px; color: var(--text) !important; } +.fo-page { + --gutter: clamp(16px, 4vw, 56px); +} +.fo-top-bar { + display: flex; + align-items: center; + gap: 28px; + padding: 18px var(--gutter); + border-bottom: 1px solid var(--line); + font-size: 14px; + color: var(--muted); +} +.fo-brand { + margin-right: auto; + font-family: var(--serif); + font-size: 24px; + color: var(--text) !important; +} /* Samme grønne knapp som «Min profil» på hovedsiden (App.css) */ -.fo-profile-link { padding: 9px 14px; border-radius: 20px; background: var(--green); color: #fff !important; font-size: 12px; font-weight: 700; } -.fo-profile-link:hover { background: #0d4f3b; } -.fo-top-bar .is-on { color: var(--green); font-weight: 600; border-bottom: 2px solid var(--copper); padding-bottom: 4px; } -.fo-hero { max-width: 1440px; margin: 0 auto; padding: clamp(20px, 2.5vw, 36px) var(--gutter) 28px; } -.fo-hero h1 { margin: 0; font-family: var(--serif); font-weight: 400; font-size: clamp(56px, 9vw, 132px); letter-spacing: -0.035em; line-height: 0.95; } -.fo-hero p { max-width: 36em; margin: 24px 0 0; font-size: clamp(16px, 1.3vw, 19px); line-height: 1.6; color: var(--muted); } -.fo-filters { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 24px; display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between; } -.fo-filters .fo-search input { width: 260px; margin: 0; } -.fo-messages { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter); } +.fo-profile-link { + padding: 9px 14px; + border-radius: 20px; + background: var(--green); + color: #fff !important; + font-size: 12px; + font-weight: 700; +} +.fo-profile-link:hover { + background: #0d4f3b; +} +.fo-top-bar .is-on { + color: var(--green); + font-weight: 600; + border-bottom: 2px solid var(--copper); + padding-bottom: 4px; +} +.fo-hero { + max-width: 1440px; + margin: 0 auto; + padding: clamp(20px, 2.5vw, 36px) var(--gutter) 28px; +} +.fo-hero h1 { + margin: 0; + font-family: var(--serif); + font-weight: 400; + font-size: clamp(56px, 9vw, 132px); + letter-spacing: -0.035em; + line-height: 0.95; +} +.fo-hero p { + max-width: 36em; + margin: 24px 0 0; + font-size: clamp(16px, 1.3vw, 19px); + line-height: 1.6; + color: var(--muted); +} +.fo-filters { + max-width: 1440px; + margin: 0 auto; + padding: 0 var(--gutter) 24px; + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: center; + justify-content: space-between; +} +.fo-filters .fo-search input { + width: 260px; + margin: 0; +} +.fo-messages { + max-width: 1440px; + margin: 0 auto; + padding: 0 var(--gutter); +} /* Søketreff blant hytter og topper, over kortene */ -.fo-hits { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 8px; } -.fo-hits a { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; background: #fff; color: inherit; text-decoration: none; } -.fo-hits a:hover { border-color: var(--green); } -.fo-grid { max-width: 1440px; margin: 0 auto; padding: 0 var(--gutter) 48px; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: clamp(14px, 1.6vw, 24px); } -.fo-grid .fo-card { padding: 0; gap: 0; background: #fff; border-radius: 22px; overflow: hidden; box-shadow: 0 20px 50px -34px rgba(35, 58, 48, 0.25); transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.45s, border-color 0.3s; } -.fo-grid .fo-card:hover { transform: translateY(-5px); box-shadow: 0 34px 70px -30px rgba(18, 107, 81, 0.4); } -.fo-card-map { position: relative; aspect-ratio: 16 / 10; background: #e3ebdf; overflow: hidden; } -.fo-card-map img { width: 100%; height: 100%; object-fit: cover; transition: transform 1s cubic-bezier(0.22, 1, 0.36, 1); } -.fo-card:hover .fo-card-map img { transform: scale(1.06); } -.fo-card-map span { position: absolute; top: 14px; left: 14px; padding: 5px 11px; border-radius: 999px; background: rgba(255, 255, 255, 0.9); font-size: 12px; font-weight: 600; } -.fo-card-body { display: flex; flex-direction: column; gap: 12px; flex: 1; padding: 20px 22px 22px; } -.fo-card-body h3 { display: flex; justify-content: space-between; margin: 0; font-family: var(--serif); font-weight: 400; font-size: clamp(28px, 2.4vw, 36px); letter-spacing: -0.02em; line-height: 1; } -.fo-card-body h3 span { color: var(--green); font-size: 20px; } -.fo-card-body > p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--muted); } -.fo-card-body .fo-counts { margin-top: auto; } +.fo-hits { + max-width: 1440px; + margin: 0 auto; + padding: 0 var(--gutter) 24px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + gap: 8px; +} +.fo-hits a { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + color: inherit; + text-decoration: none; +} +.fo-hits a:hover { + border-color: var(--green); +} +.fo-grid { + max-width: 1440px; + margin: 0 auto; + padding: 0 var(--gutter) 48px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); + gap: clamp(14px, 1.6vw, 24px); +} +.fo-grid .fo-card { + padding: 0; + gap: 0; + background: #fff; + border-radius: 22px; + overflow: hidden; + box-shadow: 0 20px 50px -34px rgba(35, 58, 48, 0.25); + transition: + transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.45s, + border-color 0.3s; +} +.fo-grid .fo-card:hover { + transform: translateY(-5px); + box-shadow: 0 34px 70px -30px rgba(18, 107, 81, 0.4); +} +.fo-card-map { + position: relative; + aspect-ratio: 16 / 10; + background: #e3ebdf; + overflow: hidden; +} +.fo-card-map img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 1s cubic-bezier(0.22, 1, 0.36, 1); +} +.fo-card:hover .fo-card-map img { + transform: scale(1.06); +} +.fo-card-map span { + position: absolute; + top: 14px; + left: 14px; + padding: 5px 11px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.9); + font-size: 12px; + font-weight: 600; +} +.fo-card-body { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + padding: 20px 22px 22px; +} +.fo-card-body h3 { + display: flex; + justify-content: space-between; + margin: 0; + font-family: var(--serif); + font-weight: 400; + font-size: clamp(28px, 2.4vw, 36px); + letter-spacing: -0.02em; + line-height: 1; +} +.fo-card-body h3 span { + color: var(--green); + font-size: 20px; +} +.fo-card-body > p { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--muted); +} +.fo-card-body .fo-counts { + margin-top: auto; +} /* ── Ett område: kartet fyller hele skjermen, og alt annet ligger oppå ── */ -.fo-mapview { position: fixed; inset: 0; overflow: hidden; } -.fo-map { position: absolute; inset: 0; } +.fo-mapview { + position: fixed; + inset: 0; + overflow: hidden; +} +.fo-map { + position: absolute; + inset: 0; +} /* ── Panelet til venstre ── */ -.fo-panel { position: absolute; top: 12px; left: 12px; bottom: 12px; z-index: 2; display: flex; flex-direction: column; width: min(420px, calc(100% - 24px)); border-radius: 22px; background: #fff; box-shadow: var(--shadow); overflow: hidden; } +.fo-panel { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + z-index: 2; + display: flex; + flex-direction: column; + width: min(420px, calc(100% - 24px)); + border-radius: 22px; + background: #fff; + box-shadow: var(--shadow); + overflow: hidden; +} /* Sammenfelt panel: bare toppen vises, og pila snus */ -.fo-panel.is-closed { bottom: auto; } -.fo-panel.is-closed .fo-panel-body { display: none; } -.fo-panel.is-closed .fo-collapse span { transform: rotate(180deg); } +.fo-panel.is-closed { + bottom: auto; +} +.fo-panel.is-closed .fo-panel-body { + display: none; +} +.fo-panel.is-closed .fo-collapse span { + transform: rotate(180deg); +} -.fo-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px 22px; border-bottom: 1px solid var(--line); } -.fo-panel-head h1 { margin: 0; font-family: var(--serif); font-weight: 400; font-size: 26px; letter-spacing: -0.01em; } -.fo-back { font-size: 14px; font-weight: 600; color: var(--muted) !important; } -.fo-back:hover { color: var(--green) !important; } -.fo-collapse { width: 38px; height: 38px; flex-shrink: 0; border: 0; border-radius: 50%; background: var(--soft); cursor: pointer; } -.fo-collapse:hover { background: var(--line); } -.fo-collapse span { display: inline-block; font-size: 18px; line-height: 1; transition: transform 0.3s; } -.fo-panel-body { flex: 1; overflow-y: auto; padding: 14px 22px 22px; scrollbar-width: thin; } +.fo-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 16px 12px 22px; + border-bottom: 1px solid var(--line); +} +.fo-panel-head h1 { + margin: 0; + font-family: var(--serif); + font-weight: 400; + font-size: 26px; + letter-spacing: -0.01em; +} +.fo-back { + font-size: 14px; + font-weight: 600; + color: var(--muted) !important; +} +.fo-back:hover { + color: var(--green) !important; +} +.fo-collapse { + width: 38px; + height: 38px; + flex-shrink: 0; + border: 0; + border-radius: 50%; + background: var(--soft); + cursor: pointer; +} +.fo-collapse:hover { + background: var(--line); +} +.fo-collapse span { + display: inline-block; + font-size: 18px; + line-height: 1; + transition: transform 0.3s; +} +.fo-panel-body { + flex: 1; + overflow-y: auto; + padding: 14px 22px 22px; + scrollbar-width: thin; +} -.fo-chips { display: flex; flex-wrap: wrap; gap: 6px; } -.fo-chips button { height: 34px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line); background: #fff; font-weight: 600; font-size: 13px; cursor: pointer; } -.fo-chips button:hover { border-color: var(--green); } -.fo-chips button[aria-pressed='true'] { background: var(--green); border-color: var(--green); color: #fff; } -.fo-search input { width: 100%; height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--line); background: var(--soft); font-size: 14px; } -.fo-card { display: flex; flex-direction: column; border: 1px solid var(--line); } -.fo-card:hover { border-color: var(--green); } -.fo-counts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0; padding-top: 10px; border-top: 1px solid var(--line); } -.fo-counts div { display: flex; flex-direction: column-reverse; } -.fo-counts dd { margin: 0; font-family: var(--serif); font-size: 20px; line-height: 1; } -.fo-counts dt { margin-top: 3px; font-size: 11px; color: var(--muted); } -.fo-top { margin: 0; font-size: 12px; color: var(--muted); } -.fo-top strong { color: var(--text); font-weight: 600; } -.fo-top span { color: var(--copper); font-weight: 600; } +.fo-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.fo-chips button { + height: 34px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: #fff; + font-weight: 600; + font-size: 13px; + cursor: pointer; +} +.fo-chips button:hover { + border-color: var(--green); +} +.fo-chips button[aria-pressed='true'] { + background: var(--green); + border-color: var(--green); + color: #fff; +} +.fo-search input { + width: 100%; + height: 40px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--soft); + font-size: 14px; +} +.fo-card { + display: flex; + flex-direction: column; + border: 1px solid var(--line); +} +.fo-card:hover { + border-color: var(--green); +} +.fo-counts { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin: 0; + padding-top: 10px; + border-top: 1px solid var(--line); +} +.fo-counts div { + display: flex; + flex-direction: column-reverse; +} +.fo-counts dd { + margin: 0; + font-family: var(--serif); + font-size: 20px; + line-height: 1; +} +.fo-counts dt { + margin-top: 3px; + font-size: 11px; + color: var(--muted); +} +.fo-top { + margin: 0; + font-size: 12px; + color: var(--muted); +} +.fo-top strong { + color: var(--text); + font-weight: 600; +} +.fo-top span { + color: var(--copper); + font-weight: 600; +} /* ── Ett område ── */ -.fo-title { margin: 0; font-family: var(--serif); font-weight: 400; font-size: clamp(38px, 4vw, 52px); letter-spacing: -0.03em; line-height: 1; } -.fo-desc { margin: 12px 0 18px; font-size: 14px; line-height: 1.6; color: var(--muted); } -.fo-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 16px; } -.fo-stats div { display: flex; flex-direction: column; gap: 4px; padding: 12px 10px; border-radius: 12px; border: 1px solid var(--line); border-top: 2px solid; background: var(--bg); } -.fo-stats strong { font-family: var(--serif); font-weight: 400; font-size: 26px; line-height: 1; } -.fo-stats span { font-size: 11px; color: var(--muted); } -.fo-peak { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding: 14px 16px; border-radius: 14px; border: 1px solid rgba(194, 119, 48, 0.45); background: linear-gradient(135deg, rgba(194, 119, 48, 0.12), rgba(194, 119, 48, 0.02)); } -.fo-peak .fo-kicker { margin: 0; } -.fo-peak strong { font-family: var(--serif); font-weight: 400; font-size: 20px; } -.fo-peak-elev { font-family: var(--serif); font-size: 24px; color: var(--green); white-space: nowrap; } -.fo-peak-elev small { font-family: var(--sans); font-size: 12px; color: var(--muted); } -.fo-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--soft); } -.fo-tabs button { flex: 1; min-height: 34px; border: 0; border-radius: 9px; background: none; font-weight: 600; font-size: 12px; color: var(--muted); cursor: pointer; } -.fo-tabs button span { opacity: 0.6; font-weight: 500; } -.fo-tabs button[aria-pressed='true'] { background: #fff; color: var(--text); box-shadow: 0 1px 4px rgba(35, 58, 48, 0.18); } -.fo-rows { margin: 6px 0 0; padding: 0; list-style: none; } -.fo-rows li { border-bottom: 1px solid var(--line); } -.fo-rows button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 6px; border: 0; border-radius: 10px; background: none; text-align: left; cursor: pointer; } -.fo-rows button:hover, .fo-rows button[aria-pressed='true'] { background: var(--soft); } -.fo-rows button > span:last-child { color: var(--green); } -.fo-row-text { flex: 1; min-width: 0; } -.fo-row-text strong { display: block; font-size: 14px; } -.fo-row-text small { font-size: 12px; color: var(--muted); } +.fo-title { + margin: 0; + font-family: var(--serif); + font-weight: 400; + font-size: clamp(38px, 4vw, 52px); + letter-spacing: -0.03em; + line-height: 1; +} +.fo-desc { + margin: 12px 0 18px; + font-size: 14px; + line-height: 1.6; + color: var(--muted); +} +.fo-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-bottom: 16px; +} +.fo-stats div { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 10px; + border-radius: 12px; + border: 1px solid var(--line); + border-top: 2px solid; + background: var(--bg); +} +.fo-stats strong { + font-family: var(--serif); + font-weight: 400; + font-size: 26px; + line-height: 1; +} +.fo-stats span { + font-size: 11px; + color: var(--muted); +} +.fo-peak { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(194, 119, 48, 0.45); + background: linear-gradient(135deg, rgba(194, 119, 48, 0.12), rgba(194, 119, 48, 0.02)); +} +.fo-peak .fo-kicker { + margin: 0; +} +.fo-peak strong { + font-family: var(--serif); + font-weight: 400; + font-size: 20px; +} +.fo-peak-elev { + font-family: var(--serif); + font-size: 24px; + color: var(--green); + white-space: nowrap; +} +.fo-peak-elev small { + font-family: var(--sans); + font-size: 12px; + color: var(--muted); +} +.fo-tabs { + display: flex; + gap: 4px; + padding: 4px; + border-radius: 12px; + background: var(--soft); +} +.fo-tabs button { + flex: 1; + min-height: 34px; + border: 0; + border-radius: 9px; + background: none; + font-weight: 600; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} +.fo-tabs button span { + opacity: 0.6; + font-weight: 500; +} +.fo-tabs button[aria-pressed='true'] { + background: #fff; + color: var(--text); + box-shadow: 0 1px 4px rgba(35, 58, 48, 0.18); +} +.fo-rows { + margin: 6px 0 0; + padding: 0; + list-style: none; +} +.fo-rows li { + border-bottom: 1px solid var(--line); +} +.fo-rows button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 6px; + border: 0; + border-radius: 10px; + background: none; + text-align: left; + cursor: pointer; +} +.fo-rows button:hover, +.fo-rows button[aria-pressed='true'] { + background: var(--soft); +} +.fo-rows button > span:last-child { + color: var(--green); +} +.fo-row-text { + flex: 1; + min-width: 0; +} +.fo-row-text strong { + display: block; + font-size: 14px; +} +.fo-row-text small { + font-size: 12px; + color: var(--muted); +} /* ── Knapper og boks oppå kartet ── */ -.fo-basemap-btn, .fo-selected { position: absolute; z-index: 1; border: 1px solid var(--line); background: rgba(255, 255, 255, 0.95); box-shadow: var(--shadow); } -.fo-basemap-btn { top: 12px; right: 12px; height: 40px; padding: 0 18px; border-radius: 999px; font-weight: 600; font-size: 14px; cursor: pointer; } -.fo-basemap-btn:hover { border-color: var(--green); } -.fo-selected { right: 12px; bottom: 40px; display: flex; align-items: center; gap: 14px; width: min(340px, calc(100% - 24px)); padding: 14px 18px; border-radius: 18px; } -.fo-selected > div { flex: 1; } -.fo-selected .fo-kicker { margin: 0; } -.fo-selected strong { display: block; font-family: var(--serif); font-weight: 400; font-size: 20px; line-height: 1.1; } -.fo-selected small { font-size: 13px; color: var(--muted); } -.fo-selected button { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg); cursor: pointer; } +.fo-basemap-btn, +.fo-selected { + position: absolute; + z-index: 1; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.95); + box-shadow: var(--shadow); +} +.fo-basemap-btn { + top: 12px; + right: 12px; + height: 40px; + padding: 0 18px; + border-radius: 999px; + font-weight: 600; + font-size: 14px; + cursor: pointer; +} +.fo-basemap-btn:hover { + border-color: var(--green); +} +.fo-selected { + right: 12px; + bottom: 40px; + display: flex; + align-items: center; + gap: 14px; + width: min(340px, calc(100% - 24px)); + padding: 14px 18px; + border-radius: 18px; +} +.fo-selected > div { + flex: 1; +} +.fo-selected .fo-kicker { + margin: 0; +} +.fo-selected strong { + display: block; + font-family: var(--serif); + font-weight: 400; + font-size: 20px; + line-height: 1.1; +} +.fo-selected small { + font-size: 13px; + color: var(--muted); +} +.fo-selected button { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--line); + background: var(--bg); + cursor: pointer; +} /* Boksen som vises når musepekeren er over et punkt */ -.fo-hover { pointer-events: none; } -.fo-hover .maplibregl-popup-content { padding: 8px 12px; border-radius: 10px; box-shadow: var(--shadow); font-family: var(--sans); } -.fo-hover strong { display: block; font-size: 14px; color: var(--text); } -.fo-hover small { font-size: 12px; color: var(--muted); } +.fo-hover { + pointer-events: none; +} +.fo-hover .maplibregl-popup-content { + padding: 8px 12px; + border-radius: 10px; + box-shadow: var(--shadow); + font-family: var(--sans); +} +.fo-hover strong { + display: block; + font-size: 14px; + color: var(--text); +} +.fo-hover small { + font-size: 12px; + color: var(--muted); +} /* På mobil ligger panelet øverst og tar halve skjermen, så kartet synes under */ @media (max-width: 760px) { - .fo-filters .fo-search, .fo-filters .fo-search input { width: 100%; } - .fo-panel { bottom: auto; max-height: 55dvh; } - .fo-basemap-btn { top: auto; bottom: 40px; left: 12px; right: auto; } - .fo-selected { left: 12px; width: auto; bottom: 92px; } + .fo-filters .fo-search, + .fo-filters .fo-search input { + width: 100%; + } + .fo-panel { + bottom: auto; + max-height: 55dvh; + } + .fo-basemap-btn { + top: auto; + bottom: 40px; + left: 12px; + right: auto; + } + .fo-selected { + left: 12px; + width: auto; + bottom: 92px; + } } diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx index 50f72b7..7a99847 100644 --- a/src/Fjellomrade.tsx +++ b/src/Fjellomrade.tsx @@ -7,7 +7,12 @@ import { TOKEN_KEY } from './graphql' // Et søketreff legger også til punktet (#/jotunheimen/n/791106132), så kartet åpnes med punktet valgt. import { useEffect, useRef, useState } from 'react' import * as maplibregl from 'maplibre-gl' -import type { ExpressionSpecification, GeoJSONSource, StyleSpecification, SymbolLayerSpecification } from 'maplibre-gl' +import type { + ExpressionSpecification, + GeoJSONSource, + StyleSpecification, + SymbolLayerSpecification, +} from 'maplibre-gl' import type { Feature, FeatureCollection, MultiPolygon, Point, Polygon, Position } from 'geojson' import 'maplibre-gl/dist/maplibre-gl.css' import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' @@ -27,19 +32,39 @@ type PointOfInterest = { lat: number elevation: number | null capacity: number | null - inside: boolean // false for punktene opptil 10 km utenfor grensen, som bare vises svakt på kartet + inside: boolean // false for punktene opptil 10 km utenfor grensen, som bare vises svakt på kartet } // Navn, farge og ikon for hver type. Rekkefølgen her er rekkefølgen i fanene. // Ikonet er en SVG-sti i et rutenett på 24 × 24 som tegnes med hvit strek i en farget sirkel på kartet. const KINDS: Record = { - HUT: { short: 'Hytter', one: 'Turisthytte', color: '#c0392b', icon: 'M5 12 12 6l7 6M7 11v7h10v-7M11 18v-4h2v4' }, - CABIN: { short: 'Buer', one: 'Bu / koie', color: '#c27730', icon: 'M6 18v-6l6-5 6 5v6zM10 18v-3h4v3' }, - SHELTER: { short: 'Gapahuker', one: 'Gapahuk', color: '#2f8f5b', icon: 'M5 18 12 6l7 12M9 18l3-5 3 5' }, + HUT: { + short: 'Hytter', + one: 'Turisthytte', + color: '#c0392b', + icon: 'M5 12 12 6l7 6M7 11v7h10v-7M11 18v-4h2v4', + }, + CABIN: { + short: 'Buer', + one: 'Bu / koie', + color: '#c27730', + icon: 'M6 18v-6l6-5 6 5v6zM10 18v-3h4v3', + }, + SHELTER: { + short: 'Gapahuker', + one: 'Gapahuk', + color: '#2f8f5b', + icon: 'M5 18 12 6l7 12M9 18l3-5 3 5', + }, PEAK: { short: 'Topper', one: 'Topp', color: '#203b32', icon: 'm4 18 6-10 3 5 2-3 5 8z' }, } const ALL_KINDS = Object.keys(KINDS) as PoiKind[] -const COLOR_BY_KIND = ['match', ['get', 'kind'], ...ALL_KINDS.flatMap(k => [k, KINDS[k].color]), '#999'] as unknown as ExpressionSpecification +const COLOR_BY_KIND = [ + 'match', + ['get', 'kind'], + ...ALL_KINDS.flatMap((k) => [k, KINDS[k].color]), + '#999', +] as unknown as ExpressionSpecification // Tegner kartikonet for en type: farget sirkel med hvit kant og hvitt symbol. Tegnes dobbelt så stort // som det vises (pixelRatio 2 i addImage), så det blir skarpt på skjermer med høy oppløsning. @@ -64,7 +89,12 @@ function drawIcon(kind: PoiKind) { // Antallet som allerede er talt opp i databasen for hvert område, per type const countOf = (area: MountainArea, kind: PoiKind) => - ({ HUT: area.hutCount, CABIN: area.cabinCount, SHELTER: area.shelterCount, PEAK: area.peakCount })[kind] + ({ + HUT: area.hutCount, + CABIN: area.cabinCount, + SHELTER: area.shelterCount, + PEAK: area.peakCount, + })[kind] // Grov inndeling i landsdel etter breddegraden midt i området type Region = 'S' | 'M' | 'N' @@ -74,7 +104,8 @@ function regionOf(area: MountainArea): Region { return lat >= 65.5 ? 'N' : lat >= 62 ? 'M' : 'S' } -const KARTVERKET_TILES = 'https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png' +const KARTVERKET_TILES = + 'https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png' const MAP_STYLE: StyleSpecification = { version: 8, sources: { @@ -83,12 +114,15 @@ const MAP_STYLE: StyleSpecification = { tiles: [KARTVERKET_TILES], tileSize: 256, maxzoom: 18, - attribution: '© Kartverket · © OpenStreetMap-bidragsytere', + attribution: + '© Kartverket · © OpenStreetMap-bidragsytere', }, // Flyfoto fra Esri, samme kilde som i ex P2. Laget er skjult til brukeren bytter til flyfoto. satellitt: { type: 'raster', - tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], + tiles: [ + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + ], tileSize: 256, maxzoom: 18, attribution: 'Bilder © Esri, Maxar, Earthstar Geographics', @@ -106,7 +140,9 @@ function tileUrl(area: MountainArea, z = 7) { const lat = ((area.bbox[1] + area.bbox[3]) / 2) * (Math.PI / 180) const x = Math.floor(((lon + 180) / 360) * 2 ** z) const y = Math.floor(((1 - Math.log(Math.tan(lat) + 1 / Math.cos(lat)) / Math.PI) / 2) * 2 ** z) - return KARTVERKET_TILES.replace('{z}', String(z)).replace('{y}', String(y)).replace('{x}', String(x)) + return KARTVERKET_TILES.replace('{z}', String(z)) + .replace('{y}', String(y)) + .replace('{x}', String(x)) } const POI_QUERY = ` @@ -124,14 +160,22 @@ type SearchHit = { areaId: string; point: PointOfInterest } // Backend henter punktene i området og like utenfor. inside er true for dem som er med i opptellingen. async function fetchPoints(areaId: string, signal: AbortSignal): Promise { - const data = await graphql<{ areaPointsOfInterest: PointOfInterest[] }>(POI_QUERY, { areaId }, signal) + const data = await graphql<{ areaPointsOfInterest: PointOfInterest[] }>( + POI_QUERY, + { areaId }, + signal, + ) return data.areaPointsOfInterest } function pointsToGeoJson(points: PointOfInterest[]): FeatureCollection { return { type: 'FeatureCollection', - features: points.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, properties: p })), + features: points.map((p) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, + properties: p, + })), } } @@ -141,15 +185,34 @@ const boundaryOf = (area: MountainArea) => JSON.parse(area.geojson) as Polygon | // Hullene er ytterkanten av hver del av grensen, så det virker både for Polygon og MultiPolygon. function maskAround(area: MountainArea): Feature { const geometry = boundaryOf(area) - const parts: Position[][][] = geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates - const world = [[-180, -85], [180, -85], [180, 85], [-180, 85], [-180, -85]] - return { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [world, ...parts.map(rings => rings[0])] } } + const parts: Position[][][] = + geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates + const world = [ + [-180, -85], + [180, -85], + [180, 85], + [-180, 85], + [-180, -85], + ] + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [world, ...parts.map((rings) => rings[0])] }, + } } // Undertekst for et punkt i lista og i boksen for valgt punkt function describe(p: PointOfInterest) { if (p.kind === 'PEAK') return p.elevation !== null ? `${p.elevation} moh.` : 'Ukjent høyde' - return [p.capacity !== null && `${p.capacity} sengeplasser`, p.dntType, p.elevation !== null && `${p.elevation} moh.`].filter(Boolean).join(' · ') || KINDS[p.kind].one + return ( + [ + p.capacity !== null && `${p.capacity} sengeplasser`, + p.dntType, + p.elevation !== null && `${p.elevation} moh.`, + ] + .filter(Boolean) + .join(' · ') || KINDS[p.kind].one + ) } // Innholdet i boksen som vises når musepekeren er over et punkt. @@ -166,7 +229,9 @@ function hoverContent(p: PointOfInterest) { // #/jotunheimen gir området, og #/jotunheimen/n/791106132 gir også punktet som skal velges function readHash() { - const [areaId, ...point] = decodeURIComponent(window.location.hash.replace(/^#\/?/, '')).split('/') + const [areaId, ...point] = decodeURIComponent(window.location.hash.replace(/^#\/?/, '')).split( + '/', + ) return { areaId: areaId || null, pointId: point.join('/') || null } } @@ -179,19 +244,32 @@ export default function Fjellomrade() { controller = new AbortController() const current = controller const token = localStorage.getItem(TOKEN_KEY) - if (!token) { setAuthenticated(false); return } - void graphql<{me: {username: string} | null}>('query($token:String!){me(token:$token){username}}', {token}, current.signal) - .then(data => { + if (!token) { + setAuthenticated(false) + return + } + void graphql<{ me: { username: string } | null }>( + 'query($token:String!){me(token:$token){username}}', + { token }, + current.signal, + ) + .then((data) => { if (current.signal.aborted || localStorage.getItem(TOKEN_KEY) !== token) return setAuthenticated(Boolean(data.me)) if (!data.me) localStorage.removeItem(TOKEN_KEY) }) - .catch(() => { if (!current.signal.aborted) setAuthenticated(false) }) + .catch(() => { + if (!current.signal.aborted) setAuthenticated(false) + }) } checkSession() window.addEventListener('storage', checkSession) window.addEventListener('focus', checkSession) - return () => { controller?.abort(); window.removeEventListener('storage', checkSession); window.removeEventListener('focus', checkSession) } + return () => { + controller?.abort() + window.removeEventListener('storage', checkSession) + window.removeEventListener('focus', checkSession) + } }, []) const [areas, setAreas] = useState(null) const [error, setError] = useState('') @@ -200,13 +278,15 @@ export default function Fjellomrade() { const overviewScroll = useRef(0) useEffect(() => { - fetchMountainAreas().then(setAreas).catch((e: unknown) => setError(e instanceof Error ? e.message : 'Noe gikk galt')) + fetchMountainAreas() + .then(setAreas) + .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Noe gikk galt')) const onHashChange = () => setRoute(readHash()) window.addEventListener('hashchange', onHashChange) return () => window.removeEventListener('hashchange', onHashChange) }, []) - const area = areas?.find(a => a.id === route.areaId) ?? null + const area = areas?.find((a) => a.id === route.areaId) ?? null useEffect(() => { if (!area) window.scrollTo(0, overviewScroll.current) @@ -214,48 +294,80 @@ export default function Fjellomrade() { if (area) return return ( - { overviewScroll.current = window.scrollY }} /> + { + overviewScroll.current = window.scrollY + }} + /> ) } -type OverviewProps = { authenticated: boolean; areas: MountainArea[] | null; error: string; notFound: string | null; onOpen: () => void } +type OverviewProps = { + authenticated: boolean + areas: MountainArea[] | null + error: string + notFound: string | null + onOpen: () => void +} function Overview({ authenticated, areas, error, notFound, onOpen }: OverviewProps) { const [region, setRegion] = useState('all') const [query, setQuery] = useState('') const needle = query.trim().toLowerCase() const shown = (areas ?? []) - .filter(a => (region === 'all' || regionOf(a) === region) && (!needle || `${a.name} ${a.description ?? ''}`.toLowerCase().includes(needle))) + .filter( + (a) => + (region === 'all' || regionOf(a) === region) && + (!needle || `${a.name} ${a.description ?? ''}`.toLowerCase().includes(needle)), + ) .sort((a, b) => (b.highestPeak?.elevation ?? 0) - (a.highestPeak?.elevation ?? 0)) // Søket finner også hytter og topper. Backend spørres en kort stund etter at man har sluttet å skrive, // og treffene huskes sammen med søketeksten, så gamle treff ikke vises for et nytt søk. - const [search, setSearch] = useState<{ needle: string; hits: SearchHit[] }>({ needle: '', hits: [] }) + const [search, setSearch] = useState<{ needle: string; hits: SearchHit[] }>({ + needle: '', + hits: [], + }) useEffect(() => { if (needle.length < 2) return const controller = new AbortController() const timer = setTimeout(() => { - graphql<{ searchPointsOfInterest: SearchHit[] }>(SEARCH_QUERY, { query: needle }, controller.signal) - .then(data => setSearch({ needle, hits: data.searchPointsOfInterest })) - .catch(() => { /* søket i hytter og topper er et tillegg, så feil vises ikke */ }) + graphql<{ searchPointsOfInterest: SearchHit[] }>( + SEARCH_QUERY, + { query: needle }, + controller.signal, + ) + .then((data) => setSearch({ needle, hits: data.searchPointsOfInterest })) + .catch(() => { + /* søket i hytter og topper er et tillegg, så feil vises ikke */ + }) }, 250) - return () => { clearTimeout(timer); controller.abort() } + return () => { + clearTimeout(timer) + controller.abort() + } }, [needle]) const hits = search.needle === needle ? search.hits : [] - const areaName = (id: string) => areas?.find(a => a.id === id)?.name ?? id + const areaName = (id: string) => areas?.find((a) => a.id === id)?.name ?? id return (

    Fjellområder i Norge

    -

    Velg et fjellområde for å åpne kartet, med alle hyttene, buene, gapahukene og toppene på ett sted.

    +

    + Velg et fjellområde for å åpne kartet, med alle hyttene, buene, gapahukene og toppene på + ett sted. +

    - {(['all', 'S', 'M', 'N'] as const).map(r => ( + {(['all', 'S', 'M', 'N'] as const).map((r) => ( @@ -263,15 +375,30 @@ function Overview({ authenticated, areas, error, notFound, onOpen }: OverviewPro
    - {error &&

    Fikk ikke hentet fjellområdene: {error}

    } + {error && ( +

    + Fikk ikke hentet fjellområdene: {error} +

    + )} {!areas && !error &&

    Henter fjellområder …

    } - {notFound &&

    Fant ikke fjellområdet «{notFound}». Velg et av områdene under.

    } - {areas && shown.length === 0 && hits.length === 0 &&

    Ingen fjellområder, hytter eller topper passer søket.

    } + {notFound && ( +

    + Fant ikke fjellområdet «{notFound}». Velg et av områdene under. +

    + )} + {areas && shown.length === 0 && hits.length === 0 && ( +

    Ingen fjellområder, hytter eller topper passer søket.

    + )}
    {hits.length > 0 && ( @@ -281,7 +408,9 @@ function Overview({ authenticated, areas, error, notFound, onOpen }: OverviewPro {point.name} - {KINDS[point.kind].one} · {areaName(areaId)} · {describe(point)} + + {KINDS[point.kind].one} · {areaName(areaId)} · {describe(point)} + ))} @@ -289,22 +418,30 @@ function Overview({ authenticated, areas, error, notFound, onOpen }: OverviewPro )}
    - {shown.map(a => ( + {shown.map((a) => (
    {REGIONS[regionOf(a)]}
    -

    {a.name}

    +

    + {a.name} +

    {a.description}

    - {ALL_KINDS.map(k => ( -
    {countOf(a, k)}
    {KINDS[k].short.toLowerCase()}
    + {ALL_KINDS.map((k) => ( +
    +
    {countOf(a, k)}
    +
    {KINDS[k].short.toLowerCase()}
    +
    ))}
    {a.highestPeak && ( -

    Høyeste topp · {a.highestPeak.name} {a.highestPeak.elevation} moh.

    +

    + Høyeste topp · {a.highestPeak.name}{' '} + {a.highestPeak.elevation} moh. +

    )}
    @@ -325,34 +462,52 @@ function AreaView({ area, pointId }: { area: MountainArea; pointId: string | nul useEffect(() => { const controller = new AbortController() fetchPoints(area.id, controller.signal) - .then(points => { + .then((points) => { setPoints(points) // Kom man fra et søketreff, velges punktet og fanen for typen dets - const fromSearch = points.find(p => p.id === pointId) + const fromSearch = points.find((p) => p.id === pointId) if (fromSearch) { setSelected(fromSearch) setTab(fromSearch.kind) } }) - .catch((e: unknown) => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : 'Noe gikk galt') }) + .catch((e: unknown) => { + if (!controller.signal.aborted) setError(e instanceof Error ? e.message : 'Noe gikk galt') + }) return () => controller.abort() }, [area, pointId]) // Toppene sorteres etter høyde, resten etter antall sengeplasser const rows = (points ?? []) - .filter(p => p.kind === tab && p.inside) - .sort((a, b) => (tab === 'PEAK' ? (b.elevation ?? 0) - (a.elevation ?? 0) : (b.capacity ?? 0) - (a.capacity ?? 0))) + .filter((p) => p.kind === tab && p.inside) + .sort((a, b) => + tab === 'PEAK' + ? (b.elevation ?? 0) - (a.elevation ?? 0) + : (b.capacity ?? 0) - (a.capacity ?? 0), + ) return (
    - setSelected(points?.find(p => p.id === id) ?? null)} /> + setSelected(points?.find((p) => p.id === id) ?? null)} + />
    {selected && (
    -
    {KINDS[selected.kind].one}{selected.name ?? 'Uten navn'}{describe(selected)}
    - +
    + {KINDS[selected.kind].one} + {selected.name ?? 'Uten navn'} + {describe(selected)} +
    +
    )}
    @@ -437,47 +618,105 @@ function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { useEffect(() => { if (!container.current) return const [west, south, east, north] = area.bbox - const map = new maplibregl.Map({ container: container.current, style: MAP_STYLE, bounds: [[west, south], [east, north]] }) + const map = new maplibregl.Map({ + container: container.current, + style: MAP_STYLE, + bounds: [ + [west, south], + [east, north], + ], + }) map.addControl(new maplibregl.NavigationControl(), 'bottom-right') mapRef.current = map map.on('load', () => { - map.addSource('area', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: boundaryOf(area) } }) - map.addLayer({ id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': '#c27730', 'fill-opacity': 0.08 } }) + map.addSource('area', { + type: 'geojson', + data: { type: 'Feature', properties: {}, geometry: boundaryOf(area) }, + }) + map.addLayer({ + id: 'area-fill', + type: 'fill', + source: 'area', + paint: { 'fill-color': '#c27730', 'fill-opacity': 0.08 }, + }) // Sløret er så svakt at kartet og punktene utenfor fortsatt er lette å lese map.addSource('mask', { type: 'geojson', data: maskAround(area) }) - map.addLayer({ id: 'mask', type: 'fill', source: 'mask', paint: { 'fill-color': '#7d8a80', 'fill-opacity': 0.15 } }) - map.addLayer({ id: 'area-line', type: 'line', source: 'area', paint: { 'line-color': '#c27730', 'line-width': 2.5, 'line-dasharray': [3, 2] } }) + map.addLayer({ + id: 'mask', + type: 'fill', + source: 'mask', + paint: { 'fill-color': '#7d8a80', 'fill-opacity': 0.15 }, + }) + map.addLayer({ + id: 'area-line', + type: 'line', + source: 'area', + paint: { 'line-color': '#c27730', 'line-width': 2.5, 'line-dasharray': [3, 2] }, + }) map.addSource('pois', { type: 'geojson', data: pointsToGeoJson([]) }) // Ring rundt det valgte punktet. Filteret settes når et punkt velges. - map.addLayer({ id: 'pois-selected', type: 'circle', source: 'pois', filter: ['==', ['get', 'id'], ''], paint: { 'circle-radius': 16, 'circle-color': COLOR_BY_KIND, 'circle-opacity': 0.25 } }) + map.addLayer({ + id: 'pois-selected', + type: 'circle', + source: 'pois', + filter: ['==', ['get', 'id'], ''], + paint: { 'circle-radius': 16, 'circle-color': COLOR_BY_KIND, 'circle-opacity': 0.25 }, + }) // Ett ikon per type (poi-HUT, poi-CABIN …), som vokser litt når man zoomer inn. // Punktene utenfor grensen er mindre og halvt gjennomsiktige. for (const kind of ALL_KINDS) map.addImage(`poi-${kind}`, drawIcon(kind), { pixelRatio: 2 }) const icon: SymbolLayerSpecification['layout'] = { 'icon-image': ['concat', 'poi-', ['get', 'kind']], - 'icon-size': ['interpolate', ['linear'], ['zoom'], 8, ['case', ['get', 'inside'], 0.7, 0.55], 13, ['case', ['get', 'inside'], 1.1, 0.85]], + 'icon-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 8, + ['case', ['get', 'inside'], 0.7, 0.55], + 13, + ['case', ['get', 'inside'], 1.1, 0.85], + ], + } + const paint: SymbolLayerSpecification['paint'] = { + 'icon-opacity': ['case', ['get', 'inside'], 1, 0.5], } - const paint: SymbolLayerSpecification['paint'] = { 'icon-opacity': ['case', ['get', 'inside'], 1, 0.5] } // Toppene får ikke ligge oppå hverandre. De høyeste plasseres først, så når man zoomer ut, // vises bare de høyeste toppene, og flere kommer til når man zoomer inn. map.addLayer({ - id: 'pois-peaks', type: 'symbol', source: 'pois', filter: ['==', ['get', 'kind'], 'PEAK'], paint, + id: 'pois-peaks', + type: 'symbol', + source: 'pois', + filter: ['==', ['get', 'kind'], 'PEAK'], + paint, layout: { ...icon, 'symbol-sort-key': ['-', ['coalesce', ['get', 'elevation'], 0]] }, }) // Hytter, buer og gapahuker vises alltid, også når de ligger tett map.addLayer({ - id: 'pois', type: 'symbol', source: 'pois', filter: ['!=', ['get', 'kind'], 'PEAK'], paint, + id: 'pois', + type: 'symbol', + source: 'pois', + filter: ['!=', ['get', 'kind'], 'PEAK'], + paint, layout: { ...icon, 'icon-allow-overlap': true }, }) const poiLayers = ['pois', 'pois-peaks'] // Kartet mister tomme verdier i egenskapene, så vi slår opp punktet på id i stedet - map.on('click', poiLayers, event => onSelectRef.current(String(event.features?.[0]?.properties.id))) + map.on('click', poiLayers, (event) => + onSelectRef.current(String(event.features?.[0]?.properties.id)), + ) // Vis navnet på punktet musepekeren er over - const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: 'fo-hover' }) - map.on('mousemove', poiLayers, event => { - const point = pointsRef.current.find(p => p.id === String(event.features?.[0]?.properties.id)) + const hover = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + offset: 12, + className: 'fo-hover', + }) + map.on('mousemove', poiLayers, (event) => { + const point = pointsRef.current.find( + (p) => p.id === String(event.features?.[0]?.properties.id), + ) if (!point) return map.getCanvas().style.cursor = 'pointer' hover.setLngLat([point.lon, point.lat]).setDOMContent(hoverContent(point)).addTo(map) @@ -499,19 +738,27 @@ function AreaMap({ area, points, selected, panelOpen, onSelect }: MapProps) { useEffect(() => { const [west, south, east, north] = area.bbox const left = panelOpen && window.innerWidth > 760 ? 460 : 60 - mapRef.current?.fitBounds([[west, south], [east, north]], { padding: { top: 60, bottom: 60, right: 60, left }, duration: 800 }) + mapRef.current?.fitBounds( + [ + [west, south], + [east, north], + ], + { padding: { top: 60, bottom: 60, right: 60, left }, duration: 800 }, + ) }, [area, panelOpen]) // 3. Tegn punktene, marker det valgte punktet og fly dit useEffect(() => { - if (loaded) (mapRef.current?.getSource('pois') as GeoJSONSource).setData(pointsToGeoJson(points)) + if (loaded) + (mapRef.current?.getSource('pois') as GeoJSONSource).setData(pointsToGeoJson(points)) }, [points, loaded]) useEffect(() => { const map = mapRef.current if (!map || !loaded) return map.setFilter('pois-selected', ['==', ['get', 'id'], selected?.id ?? '']) - if (selected) map.flyTo({ center: [selected.lon, selected.lat], zoom: Math.max(map.getZoom(), 12) }) + if (selected) + map.flyTo({ center: [selected.lon, selected.lat], zoom: Math.max(map.getZoom(), 12) }) }, [selected, loaded]) // Bytt mellom Kartverkets kart og flyfoto ved å vise eller skjule flyfotolaget diff --git a/src/Home.css b/src/Home.css index 7c63e10..2f03a15 100644 --- a/src/Home.css +++ b/src/Home.css @@ -1,267 +1,412 @@ /* Forsiden. Header-stilen kommer fra App.css. */ .home-header .header-actions { - display: flex; - gap: 1rem; - align-items: center; + display: flex; + gap: 1rem; + align-items: center; } /* Forsiden skal gå kant i kant, så vi overstyrer max-width/padding fra main i App.css */ .home-main { - max-width: none; - padding: 0; + max-width: none; + padding: 0; } .visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } /* ---------- Hero med bakgrunnsbilde ---------- */ .home-hero { - position: relative; - min-height: 560px; - display: flex; - align-items: center; - justify-content: center; - padding: 80px 5%; - background-color: #2c5a49; - /* vises hvis bildet mangler eller ikke er lastet ennå */ - background-size: cover; - background-position: 72% center; /* holder fotturisten i bildet også på smale skjermer */ + position: relative; + min-height: 560px; + display: flex; + align-items: center; + justify-content: center; + padding: 80px 5%; + background-color: #2c5a49; + /* vises hvis bildet mangler eller ikke er lastet ennå */ + background-size: cover; + background-position: 72% center; /* holder fotturisten i bildet også på smale skjermer */ } /* Mørk toning over bildet så den hvite teksten får god kontrast (WCAG) */ .home-hero::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.45)); + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.45)); } .home-hero-content { - position: relative; - width: min(100%, 960px); - text-align: center; + position: relative; + width: min(100%, 960px); + text-align: center; } .home-hero h1 { - color: #fff; - margin: 0 0 40px; - text-shadow: 0 2px 12px rgb(0 0 0 / 0.35); + color: #fff; + margin: 0 0 40px; + text-shadow: 0 2px 12px rgb(0 0 0 / 0.35); } .home-search { - display: flex; - align-items: center; - gap: 14px; - background: #fff; - border-radius: 10px; - padding: 0 24px; - box-shadow: 0 8px 30px rgb(0 0 0 / 0.2); - color: #2c6e57; + display: flex; + align-items: center; + gap: 14px; + background: #fff; + border-radius: 10px; + padding: 0 24px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.2); + color: #2c6e57; } .home-search input { - flex: 1; - min-width: 0; - border: 0; - padding: 24px 0; - font: inherit; - font-size: 20px; - color: #203b32; - background: transparent; + flex: 1; + min-width: 0; + border: 0; + padding: 24px 0; + font: inherit; + font-size: 20px; + color: #203b32; + background: transparent; } .home-search input::placeholder { - color: #2c6e57; + color: #2c6e57; } /* Skjuler den oransje fokusrammen (fra App.css) rundt søkefeltet */ .home-search input:focus-visible { - outline: none; + outline: none; } .home-search-results { - text-align: left; - margin-top: 10px; + text-align: left; + margin-top: 10px; } .home-search-results p { - color: #fff; - margin: 8px 4px; - font-size: 14px; + color: #fff; + margin: 8px 4px; + font-size: 14px; } /* Ett hvitt panel med gruppene «Steder» og «Turer» */ .home-search-panel { - background: #fff; - border-radius: 10px; - box-shadow: 0 8px 30px rgb(0 0 0 / 0.2); - padding: 6px; + background: #fff; + border-radius: 10px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.2); + padding: 6px; } .home-search-group { - margin: 10px 16px 4px; - font-size: 12px; - letter-spacing: 1.5px; - text-transform: uppercase; - color: #557365; + margin: 10px 16px 4px; + font-size: 12px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: #557365; } .home-search-results ul { - list-style: none; - margin: 0; - padding: 0; + list-style: none; + margin: 0; + padding: 0; } .home-search-results button { - width: 100%; - display: flex; - justify-content: space-between; - gap: 12px; - text-align: left; - background: transparent; - color: #203b32; - padding: 12px 16px; + width: 100%; + display: flex; + justify-content: space-between; + gap: 12px; + text-align: left; + background: transparent; + color: #203b32; + padding: 12px 16px; } .home-search-results button:hover { - background: #edf2e9; + background: #edf2e9; } .home-search-results span { - color: #5a6c61; - font-size: 14px; - white-space: nowrap; + color: #5a6c61; + font-size: 14px; + white-space: nowrap; } .home-hero-credit { - position: absolute; - right: 16px; - bottom: 8px; - margin: 0; - font-size: 13px; - color: rgb(255 255 255 / 0.85); + position: absolute; + right: 16px; + bottom: 8px; + margin: 0; + font-size: 13px; + color: rgb(255 255 255 / 0.85); } /* ---------- Turforslag i nærområdet ---------- */ .home-nearby { - max-width: 1200px; - margin: 0 auto; - padding: 72px 5%; - text-align: center; + max-width: 1200px; + margin: 0 auto; + padding: 72px 5%; + text-align: center; } .home-nearby h2 { - font-size: clamp(26px, 3.5vw, 38px); - margin: 0; + font-size: clamp(26px, 3.5vw, 38px); + margin: 0; } - - - - .home-suggestions { - list-style: none; - padding: 0; - margin: 24px 0 0; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - text-align: left; + list-style: none; + padding: 0; + margin: 24px 0 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + text-align: left; } .home-suggestions button { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - gap: 6px; - text-align: left; - background: #fff; - color: #203b32; - border: 1px solid #dce1d7; - border-radius: 14px; - padding: 20px; - overflow-wrap: anywhere; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 6px; + text-align: left; + background: #fff; + color: #203b32; + border: 1px solid #dce1d7; + border-radius: 14px; + padding: 20px; + overflow-wrap: anywhere; } .home-suggestions button:hover { - border-color: #126b51; + border-color: #126b51; } .home-suggestions strong { - font-size: 18px; + font-size: 18px; } .home-suggestion-length { - font-size: 15px; - font-weight: 600; - color: #126b51; + font-size: 15px; + font-weight: 600; + color: #126b51; } .home-suggestion-meta { - font-size: 13px; - color: #5a6c61; + font-size: 13px; + color: #5a6c61; } @media (max-width: 800px) { - .home-hero { - min-height: 440px; - } + .home-hero { + min-height: 440px; + } - .home-search input { - font-size: 17px; - padding: 18px 0; - } + .home-search input { + font-size: 17px; + padding: 18px 0; + } - .home-suggestions { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .home-suggestions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 480px) { - .home-suggestions { - grid-template-columns: 1fr; - } + .home-suggestions { + grid-template-columns: 1fr; + } } .home-nearby-subtitle { - color: #5a6c61; - margin: 8px 0 0; + color: #5a6c61; + margin: 8px 0 0; } -.home-nearby:focus { outline: none; } /* får fokus fra koden når et sted velges, trenger ingen ramme */ +.home-nearby:focus { + outline: none; +} /* får fokus fra koden når et sted velges, trenger ingen ramme */ .home-nearby-reset { - margin-top: 8px; + margin-top: 8px; } /* Forsidens naturbilde og søk, med eksisterende kommunesøk bevart. */ -.home-main{max-width:1432px;margin:0 auto;padding:24px 56px;font-family:'Avenir Next',system-ui,sans-serif} -.home-hero{min-height:480px;padding:60px;justify-content:flex-start;border-radius:16px;background-position:center;overflow:hidden} -.home-hero::before{background:linear-gradient(90deg,rgba(17,37,24,.8),rgba(17,37,24,.36) 50%,rgba(17,37,24,.06))} -.home-hero-content{width:min(670px,100%);text-align:left} -.home-eyebrow{color:#d4dfcc;letter-spacing:2.4px;font-size:10px;font-weight:600;margin:0 0 20px} -.home-hero h1{font-size:clamp(40px,5.6vw,68px);font-weight:600;line-height:1.03;letter-spacing:-2.5px;margin:0 0 22px;text-shadow:none} -.home-intro{color:#e6ebe1;font-size:16px;line-height:1.65;margin:0 0 28px} -.home-search{width:min(590px,100%);gap:12px;padding:7px 7px 7px 17px;border-radius:10px;color:#667c64;box-shadow:0 8px 24px #10211720} -.home-search svg{width:18px;height:18px;flex-shrink:0} -.home-search input{font-size:13px;padding:12px 0;color:#23382c} -.home-search input::placeholder{color:#7b8577} -.home-search:focus-within{outline:2px solid #c7d5bc;outline-offset:3px} -.home-search .home-find-button{background:#435d47;color:#fff;font-size:12px;font-weight:600;padding:13px 17px;border-radius:8px;white-space:nowrap;display:flex;gap:15px;align-items:center} -.home-search .home-find-button:hover{background:#304c37} -.home-hero-credit{right:18px;bottom:14px;padding:5px 9px;border-radius:4px;background:#152e2455;font-size:9px;color:#edf1e8} -.home-search-results{width:min(590px,100%)} -.home-search-panel{max-height:400px;overflow-y:auto} -.home-search-results button{align-items:center;min-width:0} -.home-search-results strong{overflow-wrap:anywhere} -.home-nearby{padding:48px 0;max-width:none;text-align:left} -.home-nearby h2{font-size:28px;letter-spacing:-.8px;font-weight:600} -@media(max-width:1050px){.home-main{padding:20px 28px}.home-hero{padding:40px;min-height:430px}} -@media(max-width:720px){.home-main{padding:16px}.home-hero{padding:36px 24px;min-height:430px;background-position:60% center}.home-hero h1{font-size:48px}.home-search{gap:8px;padding-left:12px}.home-search input{font-size:12px}.home-search .home-find-button{padding:12px;font-size:11px;gap:9px}.home-nearby h2{font-size:23px}.home-search-results span{white-space:normal;text-align:right;font-size:12px}} -@media(max-width:390px){.home-hero h1{font-size:42px}.home-search svg,.home-find-button span{display:none}} +.home-main { + max-width: 1432px; + margin: 0 auto; + padding: 24px 56px; + font-family: 'Avenir Next', system-ui, sans-serif; +} +.home-hero { + min-height: 480px; + padding: 60px; + justify-content: flex-start; + border-radius: 16px; + background-position: center; + overflow: hidden; +} +.home-hero::before { + background: linear-gradient( + 90deg, + rgba(17, 37, 24, 0.8), + rgba(17, 37, 24, 0.36) 50%, + rgba(17, 37, 24, 0.06) + ); +} +.home-hero-content { + width: min(670px, 100%); + text-align: left; +} +.home-eyebrow { + color: #d4dfcc; + letter-spacing: 2.4px; + font-size: 10px; + font-weight: 600; + margin: 0 0 20px; +} +.home-hero h1 { + font-size: clamp(40px, 5.6vw, 68px); + font-weight: 600; + line-height: 1.03; + letter-spacing: -2.5px; + margin: 0 0 22px; + text-shadow: none; +} +.home-intro { + color: #e6ebe1; + font-size: 16px; + line-height: 1.65; + margin: 0 0 28px; +} +.home-search { + width: min(590px, 100%); + gap: 12px; + padding: 7px 7px 7px 17px; + border-radius: 10px; + color: #667c64; + box-shadow: 0 8px 24px #10211720; +} +.home-search svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} +.home-search input { + font-size: 13px; + padding: 12px 0; + color: #23382c; +} +.home-search input::placeholder { + color: #7b8577; +} +.home-search:focus-within { + outline: 2px solid #c7d5bc; + outline-offset: 3px; +} +.home-search .home-find-button { + background: #435d47; + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 13px 17px; + border-radius: 8px; + white-space: nowrap; + display: flex; + gap: 15px; + align-items: center; +} +.home-search .home-find-button:hover { + background: #304c37; +} +.home-hero-credit { + right: 18px; + bottom: 14px; + padding: 5px 9px; + border-radius: 4px; + background: #152e2455; + font-size: 9px; + color: #edf1e8; +} +.home-search-results { + width: min(590px, 100%); +} +.home-search-panel { + max-height: 400px; + overflow-y: auto; +} +.home-search-results button { + align-items: center; + min-width: 0; +} +.home-search-results strong { + overflow-wrap: anywhere; +} +.home-nearby { + padding: 48px 0; + max-width: none; + text-align: left; +} +.home-nearby h2 { + font-size: 28px; + letter-spacing: -0.8px; + font-weight: 600; +} +@media (max-width: 1050px) { + .home-main { + padding: 20px 28px; + } + .home-hero { + padding: 40px; + min-height: 430px; + } +} +@media (max-width: 720px) { + .home-main { + padding: 16px; + } + .home-hero { + padding: 36px 24px; + min-height: 430px; + background-position: 60% center; + } + .home-hero h1 { + font-size: 48px; + } + .home-search { + gap: 8px; + padding-left: 12px; + } + .home-search input { + font-size: 12px; + } + .home-search .home-find-button { + padding: 12px; + font-size: 11px; + gap: 9px; + } + .home-nearby h2 { + font-size: 23px; + } + .home-search-results span { + white-space: normal; + text-align: right; + font-size: 12px; + } +} +@media (max-width: 390px) { + .home-hero h1 { + font-size: 42px; + } + .home-search svg, + .home-find-button span { + display: none; + } +} diff --git a/src/Home.tsx b/src/Home.tsx index 9830b58..6e44c0d 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -19,17 +19,25 @@ type Municipality = { code: string; name: string } type Place = Municipality & { routeCount: number } type RouteVariant = { name: string; number: string } type RouteGroup = { - key: string - name: string - variantCount: number - minLengthMeters: number - maxLengthMeters: number - variants: (RouteVariant & { difficulty: string | null; routeType: string | null })[] - /** Bare satt for populære turer: hvor mange brukere som har lagret turen */ - favoriteCount?: number + key: string + name: string + variantCount: number + minLengthMeters: number + maxLengthMeters: number + variants: (RouteVariant & { difficulty: string | null; routeType: string | null })[] + /** Bare satt for populære turer: hvor mange brukere som har lagret turen */ + favoriteCount?: number } type RoutePage = { routes: { total: number; items: RouteGroup[] } } -type PopularRoute = { favoriteCount: number; route: RouteVariant & { id: string; lengthMeters: number; difficulty: string | null; routeType: string | null } } +type PopularRoute = { + favoriteCount: number + route: RouteVariant & { + id: string + lengthMeters: number + difficulty: string | null + routeType: string | null + } +} const POPULAR_QUERY = `query($limit:Int!){ popularRoutes(limit:$limit){ favoriteCount route{id name number lengthMeters difficulty routeType} } @@ -37,7 +45,15 @@ const POPULAR_QUERY = `query($limit:Int!){ /** Gjør en populær tur om til samme form som et søketreff, så kortene kan vises likt */ function popularToGroup({ favoriteCount, route }: PopularRoute): RouteGroup { - return { key: route.id, name: route.name, variantCount: 1, minLengthMeters: route.lengthMeters, maxLengthMeters: route.lengthMeters, variants: [route], favoriteCount } + return { + key: route.id, + name: route.name, + variantCount: 1, + minLengthMeters: route.lengthMeters, + maxLengthMeters: route.lengthMeters, + variants: [route], + favoriteCount, + } } // Samme routes-spørring som turlisten bruker, men med få treff siden forsiden bare viser et utvalg @@ -57,184 +73,324 @@ const SEARCH_QUERY = `query($search:String!,$limit:Int!){ type SearchData = { routes: RoutePage['routes']; searchMunicipalities: Place[] } function formatLength(group: RouteGroup) { - const km = (meters: number) => (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) - return group.minLengthMeters === group.maxLengthMeters - ? `${km(group.minLengthMeters)} km` - : `${km(group.minLengthMeters)}–${km(group.maxLengthMeters)} km` + const km = (meters: number) => + (meters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 }) + return group.minLengthMeters === group.maxLengthMeters + ? `${km(group.minLengthMeters)} km` + : `${km(group.minLengthMeters)}–${km(group.maxLengthMeters)} km` } -export default function Home({ onLoginClick, onSelectRoute,authenticated=false,onProfileClick,onExploreClick }: { - authenticated?:boolean - onProfileClick?:()=>void - onExploreClick?:()=>void - onLoginClick: () => void - onSelectRoute: (route: RouteVariant) => void +export default function Home({ + onLoginClick, + onSelectRoute, + authenticated = false, + onProfileClick, + onExploreClick, +}: { + authenticated?: boolean + onProfileClick?: () => void + onExploreClick?: () => void + onLoginClick: () => void + onSelectRoute: (route: RouteVariant) => void }) { - // --- Søk --- - const [input, setInput] = useState('') - // Svaret lagres sammen med søket det hører til. Da kan status avledes under rendering - // i stedet for å settes i effekten (unngår ekstra renders). - const [searchResult, setSearchResult] = useState<{ search: string; data: SearchData | null } | null>(null) - - // Søk med debouncing: venter til brukeren har sluttet å skrive, og avbryter utdaterte requests - useEffect(() => { - const search = input.trim() - if (search.length < 2) return - const controller = new AbortController() - const timer = window.setTimeout(() => { - graphql(SEARCH_QUERY, { search, limit: 6 }, controller.signal) - .then(data => setSearchResult({ search, data })) - .catch(() => { if (!controller.signal.aborted) setSearchResult({ search, data: null }) }) - }, SEARCH_DELAY_MS) - return () => { window.clearTimeout(timer); controller.abort() } - }, [input]) - const trimmed = input.trim() - const searchStatus = trimmed.length < 2 ? 'idle' - : searchResult?.search !== trimmed ? 'loading' - : searchResult.data ? 'ready' : 'error' - const results = searchStatus === 'ready' ? searchResult!.data!.routes : null - const places = searchStatus === 'ready' ? searchResult!.data!.searchMunicipalities : [] - - // --- Nærområde --- - // Stedet velges i profilen (Profile.tsx) og lagres på brukeren i databasen. - // Forsiden leser det bare, og viser turforslag derfra. - const [token] = useState(() => localStorage.getItem(TOKEN_KEY)) - const [home, setHome] = useState(null) - // Sted valgt fra søkefeltet. Gjelder bare nå og lagres ikke, men går foran stedet i profilen. - const [place, setPlace] = useState(null) - const area = place ?? home - const nearbyRef = useRef(null) - const [suggestionResult, setSuggestionResult] = useState<{ key: string; items: RouteGroup[] | null } | null>(null) - - // Henter stedet som er lagret på brukeren, hvis man er logget inn - useEffect(() => { - if (!token) return - const controller = new AbortController() - graphql<{ me: { homeMunicipality: Municipality | null } | null }>( - 'query($token:String!){me(token:$token){homeMunicipality{code name}}}', { token }, controller.signal) - .then(data => setHome(data.me?.homeMunicipality ?? null)) - .catch(() => undefined) - return () => controller.abort() - }, [token]) - - // Turforslag: fra brukerens sted hvis det er valgt, ellers de mest populære turene i hele Norge. - // Filtreringen skjer i databasen, ikke på klienten. - const suggestionKey = area?.code ?? '' - useEffect(() => { - const controller = new AbortController() - const request = suggestionKey - ? graphql(ROUTES_QUERY, { search: '', municipality: suggestionKey, limit: 6 }, controller.signal) - .then(data => data.routes.items) - : graphql<{ popularRoutes: PopularRoute[] }>(POPULAR_QUERY, { limit: 6 }, controller.signal) - .then(data => data.popularRoutes.map(popularToGroup)) - request - .then(items => setSuggestionResult({ key: suggestionKey, items })) - .catch(() => { if (!controller.signal.aborted) setSuggestionResult({ key: suggestionKey, items: null }) }) - return () => controller.abort() - }, [suggestionKey]) - const suggestionStatus = suggestionResult?.key !== suggestionKey ? 'loading' - : suggestionResult.items ? 'ready' : 'error' - const suggestions = suggestionResult?.items ?? [] - - function choosePlace(chosen: Place) { - setPlace({ code: chosen.code, name: chosen.name }) - setInput('') - // Flytt synet (og fokus for skjermlesere) ned til turene i det valgte stedet - window.requestAnimationFrame(() => { - nearbyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - nearbyRef.current?.focus({ preventScroll: true }) + // --- Søk --- + const [input, setInput] = useState('') + // Svaret lagres sammen med søket det hører til. Da kan status avledes under rendering + // i stedet for å settes i effekten (unngår ekstra renders). + const [searchResult, setSearchResult] = useState<{ + search: string + data: SearchData | null + } | null>(null) + + // Søk med debouncing: venter til brukeren har sluttet å skrive, og avbryter utdaterte requests + useEffect(() => { + const search = input.trim() + if (search.length < 2) return + const controller = new AbortController() + const timer = window.setTimeout(() => { + graphql(SEARCH_QUERY, { search, limit: 6 }, controller.signal) + .then((data) => setSearchResult({ search, data })) + .catch(() => { + if (!controller.signal.aborted) setSearchResult({ search, data: null }) }) + }, SEARCH_DELAY_MS) + return () => { + window.clearTimeout(timer) + controller.abort() } + }, [input]) + const trimmed = input.trim() + const searchStatus = + trimmed.length < 2 + ? 'idle' + : searchResult?.search !== trimmed + ? 'loading' + : searchResult.data + ? 'ready' + : 'error' + const results = searchStatus === 'ready' ? searchResult!.data!.routes : null + const places = searchStatus === 'ready' ? searchResult!.data!.searchMunicipalities : [] + + // --- Nærområde --- + // Stedet velges i profilen (Profile.tsx) og lagres på brukeren i databasen. + // Forsiden leser det bare, og viser turforslag derfra. + const [token] = useState(() => localStorage.getItem(TOKEN_KEY)) + const [home, setHome] = useState(null) + // Sted valgt fra søkefeltet. Gjelder bare nå og lagres ikke, men går foran stedet i profilen. + const [place, setPlace] = useState(null) + const area = place ?? home + const nearbyRef = useRef(null) + const [suggestionResult, setSuggestionResult] = useState<{ + key: string + items: RouteGroup[] | null + } | null>(null) + + // Henter stedet som er lagret på brukeren, hvis man er logget inn + useEffect(() => { + if (!token) return + const controller = new AbortController() + graphql<{ me: { homeMunicipality: Municipality | null } | null }>( + 'query($token:String!){me(token:$token){homeMunicipality{code name}}}', + { token }, + controller.signal, + ) + .then((data) => setHome(data.me?.homeMunicipality ?? null)) + .catch(() => undefined) + return () => controller.abort() + }, [token]) + + // Turforslag: fra brukerens sted hvis det er valgt, ellers de mest populære turene i hele Norge. + // Filtreringen skjer i databasen, ikke på klienten. + const suggestionKey = area?.code ?? '' + useEffect(() => { + const controller = new AbortController() + const request = suggestionKey + ? graphql( + ROUTES_QUERY, + { search: '', municipality: suggestionKey, limit: 6 }, + controller.signal, + ).then((data) => data.routes.items) + : graphql<{ popularRoutes: PopularRoute[] }>( + POPULAR_QUERY, + { limit: 6 }, + controller.signal, + ).then((data) => data.popularRoutes.map(popularToGroup)) + request + .then((items) => setSuggestionResult({ key: suggestionKey, items })) + .catch(() => { + if (!controller.signal.aborted) setSuggestionResult({ key: suggestionKey, items: null }) + }) + return () => controller.abort() + }, [suggestionKey]) + const suggestionStatus = + suggestionResult?.key !== suggestionKey ? 'loading' : suggestionResult.items ? 'ready' : 'error' + const suggestions = suggestionResult?.items ?? [] - return <> - window.scrollTo({top:0,behavior:'smooth'})} onExplore={onExploreClick} onProfile={authenticated?onProfileClick:onLoginClick} /> -
    -
    { + nearbyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + nearbyRef.current?.focus({ preventScroll: true }) + }) + } + + return ( + <> + window.scrollTo({ top: 0, behavior: 'smooth' })} + onExplore={onExploreClick} + onProfile={authenticated ? onProfileClick : onLoginClick} + /> +
    +
    +
    +

    DITT NESTE PUSTEROM

    +

    + Finn veien +
    + til litt mer ute. +

    +

    + Små avbrekk. Lange eventyr. +
    + Utforsk turer og finn din egen vei. +

    +
    { + event.preventDefault() + if (trimmed.length < 2) document.getElementById('home-search-input')?.focus() + else + document + .getElementById('home-search-results') + ?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }} > -
    -

    DITT NESTE PUSTEROM

    -

    Finn veien
    til litt mer ute.

    -

    Små avbrekk. Lange eventyr.
    Utforsk turer og finn din egen vei.

    - {event.preventDefault();if(trimmed.length<2)document.getElementById('home-search-input')?.focus();else document.getElementById('home-search-results')?.scrollIntoView({behavior:'smooth',block:'nearest'})}}> - - - setInput(event.target.value)} - aria-describedby="home-search-status" - /> - - -
    -

    - {searchStatus === 'loading' && 'Søker …'} - {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'} - {searchStatus === 'ready' && (results?.total || places.length - ? [places.length ? `${places.length} ${places.length === 1 ? 'sted' : 'steder'}` : '', - results?.total ? `${results.total.toLocaleString('nb-NO')} turer${results.total > results.items.length ? ` (viser ${results.items.length})` : ''}` : ''] - .filter(Boolean).join(' og ') - : 'Ingen steder eller turer passer søket.')} -

    - {searchStatus === 'ready' && (places.length > 0 || (results && results.items.length > 0)) &&
    - {places.length > 0 && <> -

    Steder

    -
      - {places.map(item =>
    • - -
    • )} -
    - } - {results && results.items.length > 0 && <> -

    Turer

    + + + setInput(event.target.value)} + aria-describedby="home-search-status" + /> + + +
    +

    + {searchStatus === 'loading' && 'Søker …'} + {searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'} + {searchStatus === 'ready' && + (results?.total || places.length + ? [ + places.length + ? `${places.length} ${places.length === 1 ? 'sted' : 'steder'}` + : '', + results?.total + ? `${results.total.toLocaleString('nb-NO')} turer${results.total > results.items.length ? ` (viser ${results.items.length})` : ''}` + : '', + ] + .filter(Boolean) + .join(' og ') + : 'Ingen steder eller turer passer søket.')} +

    + {searchStatus === 'ready' && + (places.length > 0 || (results && results.items.length > 0)) && ( +
    + {places.length > 0 && ( + <> +

    Steder

      - {results.items.map(group =>
    • - -
    • )} + {places.map((item) => ( +
    • + +
    • + ))}
    - } -
    } -
    -
    - {HERO_IMAGE_CREDIT &&

    {HERO_IMAGE_CREDIT}

    } -
    - -
    -

    {area ? `Velg din neste tur i ${area.name}` : 'Velg din neste tur'}

    - {!area &&

    De mest populære turene i Norge

    } - {place && } - {suggestionStatus === 'loading' &&

    Henter turforslag …

    } - {suggestionStatus === 'error' &&

    Kunne ikke hente turforslag. Kontroller at backend kjører.

    } - {suggestionStatus === 'ready' && suggestions.length === 0 &&

    Fant ingen registrerte turer{area ? ` i ${area.name}` : ''}.

    } - {suggestionStatus === 'ready' && suggestions.length > 0 &&
      - {suggestions.map(group => { - const first = group.variants[0] - return
    • - -
    • - })} -
    } -
    -
    + + {group.variantCount > 1 + ? `${group.variantCount} varianter · ` + : ''} + {formatLength(group)} + + + + ))} + + + )} +
    + )} + + + {HERO_IMAGE_CREDIT &&

    {HERO_IMAGE_CREDIT}

    } + + +
    +

    + {area ? `Velg din neste tur i ${area.name}` : 'Velg din neste tur'} +

    + {!area &&

    De mest populære turene i Norge

    } + {place && ( + + )} + {suggestionStatus === 'loading' &&

    Henter turforslag …

    } + {suggestionStatus === 'error' && ( +

    Kunne ikke hente turforslag. Kontroller at backend kjører.

    + )} + {suggestionStatus === 'ready' && suggestions.length === 0 && ( +

    Fant ingen registrerte turer{area ? ` i ${area.name}` : ''}.

    + )} + {suggestionStatus === 'ready' && suggestions.length > 0 && ( +
      + {suggestions.map((group) => { + const first = group.variants[0] + return ( +
    • + +
    • + ) + })} +
    + )} +
    + + ) } diff --git a/src/Profile.css b/src/Profile.css index b57d9fb..8f79559 100644 --- a/src/Profile.css +++ b/src/Profile.css @@ -117,7 +117,7 @@ margin-top: 18px; } -.profile-settings input[type=file] { +.profile-settings input[type='file'] { background: #f7f8f4; } @@ -156,7 +156,7 @@ margin: 10px 0 14px; } -.auth-intro>p:last-child { +.auth-intro > p:last-child { color: #5a6c61; } @@ -168,7 +168,7 @@ margin-top: 4px; } -.auth-card>.text-button { +.auth-card > .text-button { margin-top: 18px; } @@ -198,56 +198,322 @@ font-family: inherit; cursor: pointer; } -.badge-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } -.badge-heading > span { font-size: 12px; color: #647267; } -.badge-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; list-style: none; padding: 0; } -.achievement { display: flex; gap: 12px; align-items: start; padding: 16px; border: 1px solid #dce1d7; border-radius: 12px; background: #f5f6f0; } -.achievement.earned { background: #e7f1e9; border-color: #126b51; } -.achievement-symbol { display: grid; place-items: center; flex: 0 0 42px; height: 42px; border-radius: 50%; background: #e0e5dc; font-size: 20px; font-weight: 700; } -.earned .achievement-symbol { background: #126b51; color: white; } -.achievement strong { font-size: 14px; } -.achievement-state { display: block; font-size: 11px; font-weight: 600; margin-top: 4px; } -.achievement p { font-size: 12px; color: #647267; margin-bottom: 0; } -@media(max-width:900px){.badge-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} -@media(max-width:600px){.badge-grid{grid-template-columns:1fr}} -.monthly-goal { background: #f5f6f0; border: 1px solid #dce1d7; border-radius: 14px; padding: 20px; margin: 20px 0; } -.monthly-goal h3 { margin: 0 0 14px; } -.monthly-goal form { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; } -.monthly-goal label { display: grid; gap: 6px; font-size: 13px; } -.monthly-goal input { font: inherit; border: 1px solid #9cac9e; border-radius: 8px; padding: 10px; min-width: 0; } -.goal-progress progress { width: 100%; height: 16px; accent-color: #126b51; } -.trip-diary { border-top: 1px solid #dce1d7; padding-top: 14px; margin-top: 14px; } -.trip-diary summary { cursor: pointer; font-weight: 600; } -.trip-diary form { display: grid; gap: 12px; margin-top: 14px; } -.trip-diary label { display: grid; gap: 6px; font-size: 13px; } -.trip-diary input,.trip-diary select,.trip-diary textarea { width: 100%; min-width: 0; box-sizing: border-box; font: inherit; padding: 10px; border: 1px solid #9cac9e; border-radius: 8px; background: white; } -.trip-diary textarea { resize: vertical; } -.profile-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin-top: 22px; } -.profile-stats > div { padding: 20px; border-radius: 12px; background: #edf2e9; } -.profile-stats strong { display: block; font-size: 28px; color: #126b51; } -.profile-stats span,.stats-help { color: #647267; font-size: 12px; } -.profile-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0; border-bottom: 1px solid #dce1d7; padding-bottom: 14px; } -.profile-tabs button { background: #f5f6f0; color: #203b32; border: 2px solid transparent; } -.profile-tabs button[aria-pressed=true],.selected-trip-actions button[aria-pressed=true] { background: #e7f1e9; color: #126b51; border: 2px solid #126b51; } -.selected-trip-actions button { margin: 0 8px 8px 0; border: 2px solid transparent; } -.profile-empty { background: #f5f6f0; border-radius: 12px; padding: 26px; } -.profile-empty p { color: #647267; } -.favorite-list li { background: #edf2e9; border: 1px solid #dce1d7; border-radius: 12px; padding: 12px; } -.favorite-list .trip-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } -.favorite-list .trip-actions button { width: auto; font-size: 12px; background: white; border: 1px solid #9cac9e; padding: 8px; } -@media(max-width:600px){.profile-stats{grid-template-columns:1fr}.profile-heading{flex-wrap:wrap}.profile-tabs button{flex:1 1 40%}} -@media (max-width: 600px) { .auth-main { min-height: calc(100vh - 72px); padding: 24px 16px; } .auth-card { padding: 28px 22px; } } - -.profile-split { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 32px; margin-top: 28px; } -.profile-split .profile-tabs { display: flex; flex-direction: column; align-self: start; gap: 6px; margin: 0; padding: 0 20px 0 0; border-bottom: 0; border-right: 1px solid #e0e6dc; } -.profile-split .profile-tabs button { text-align: left; width: 100%; padding: 13px 15px; border-radius: 9px; font-size: 14px; } -.profile-content { min-width: 0; } -.profile-content > div > h3:first-child { margin-top: 0; } -@media(max-width: 720px) { .profile-split { grid-template-columns: 1fr; gap: 22px; } .profile-split .profile-tabs { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid #e0e6dc; padding: 0 0 16px; } .profile-split .profile-tabs button { flex: 1 1 40%; width: auto; } } - -.password-field { position: relative; display: block; min-width: 0; border: 1px solid #9cac9e; border-radius: 8px; background: #fff; } -.password-field:focus-within { outline: 2px solid #b9803c; outline-offset: 3px; } -.profile-form .password-field input { box-sizing: border-box; width: calc(100% - 48px); border: 0; background: transparent; padding-right: 11px; outline: none; box-shadow: none; } -.profile-form .password-field .password-toggle { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 8px; border: 0; border-radius: 7px; background: transparent; color: #435e46; cursor: pointer; } -.profile-form .password-field .password-toggle:hover { background: #e9efe4; } -.profile-form .password-field .password-toggle:focus-visible { outline: 2px solid #435e46; outline-offset: 2px; } +.badge-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} +.badge-heading > span { + font-size: 12px; + color: #647267; +} +.badge-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + list-style: none; + padding: 0; +} +.achievement { + display: flex; + gap: 12px; + align-items: start; + padding: 16px; + border: 1px solid #dce1d7; + border-radius: 12px; + background: #f5f6f0; +} +.achievement.earned { + background: #e7f1e9; + border-color: #126b51; +} +.achievement-symbol { + display: grid; + place-items: center; + flex: 0 0 42px; + height: 42px; + border-radius: 50%; + background: #e0e5dc; + font-size: 20px; + font-weight: 700; +} +.earned .achievement-symbol { + background: #126b51; + color: white; +} +.achievement strong { + font-size: 14px; +} +.achievement-state { + display: block; + font-size: 11px; + font-weight: 600; + margin-top: 4px; +} +.achievement p { + font-size: 12px; + color: #647267; + margin-bottom: 0; +} +@media (max-width: 900px) { + .badge-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 600px) { + .badge-grid { + grid-template-columns: 1fr; + } +} +.monthly-goal { + background: #f5f6f0; + border: 1px solid #dce1d7; + border-radius: 14px; + padding: 20px; + margin: 20px 0; +} +.monthly-goal h3 { + margin: 0 0 14px; +} +.monthly-goal form { + display: flex; + align-items: end; + gap: 12px; + flex-wrap: wrap; +} +.monthly-goal label { + display: grid; + gap: 6px; + font-size: 13px; +} +.monthly-goal input { + font: inherit; + border: 1px solid #9cac9e; + border-radius: 8px; + padding: 10px; + min-width: 0; +} +.goal-progress progress { + width: 100%; + height: 16px; + accent-color: #126b51; +} +.trip-diary { + border-top: 1px solid #dce1d7; + padding-top: 14px; + margin-top: 14px; +} +.trip-diary summary { + cursor: pointer; + font-weight: 600; +} +.trip-diary form { + display: grid; + gap: 12px; + margin-top: 14px; +} +.trip-diary label { + display: grid; + gap: 6px; + font-size: 13px; +} +.trip-diary input, +.trip-diary select, +.trip-diary textarea { + width: 100%; + min-width: 0; + box-sizing: border-box; + font: inherit; + padding: 10px; + border: 1px solid #9cac9e; + border-radius: 8px; + background: white; +} +.trip-diary textarea { + resize: vertical; +} +.profile-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-top: 22px; +} +.profile-stats > div { + padding: 20px; + border-radius: 12px; + background: #edf2e9; +} +.profile-stats strong { + display: block; + font-size: 28px; + color: #126b51; +} +.profile-stats span, +.stats-help { + color: #647267; + font-size: 12px; +} +.profile-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 24px 0; + border-bottom: 1px solid #dce1d7; + padding-bottom: 14px; +} +.profile-tabs button { + background: #f5f6f0; + color: #203b32; + border: 2px solid transparent; +} +.profile-tabs button[aria-pressed='true'], +.selected-trip-actions button[aria-pressed='true'] { + background: #e7f1e9; + color: #126b51; + border: 2px solid #126b51; +} +.selected-trip-actions button { + margin: 0 8px 8px 0; + border: 2px solid transparent; +} +.profile-empty { + background: #f5f6f0; + border-radius: 12px; + padding: 26px; +} +.profile-empty p { + color: #647267; +} +.favorite-list li { + background: #edf2e9; + border: 1px solid #dce1d7; + border-radius: 12px; + padding: 12px; +} +.favorite-list .trip-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.favorite-list .trip-actions button { + width: auto; + font-size: 12px; + background: white; + border: 1px solid #9cac9e; + padding: 8px; +} +@media (max-width: 600px) { + .profile-stats { + grid-template-columns: 1fr; + } + .profile-heading { + flex-wrap: wrap; + } + .profile-tabs button { + flex: 1 1 40%; + } +} +@media (max-width: 600px) { + .auth-main { + min-height: calc(100vh - 72px); + padding: 24px 16px; + } + .auth-card { + padding: 28px 22px; + } +} + +.profile-split { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 32px; + margin-top: 28px; +} +.profile-split .profile-tabs { + display: flex; + flex-direction: column; + align-self: start; + gap: 6px; + margin: 0; + padding: 0 20px 0 0; + border-bottom: 0; + border-right: 1px solid #e0e6dc; +} +.profile-split .profile-tabs button { + text-align: left; + width: 100%; + padding: 13px 15px; + border-radius: 9px; + font-size: 14px; +} +.profile-content { + min-width: 0; +} +.profile-content > div > h3:first-child { + margin-top: 0; +} +@media (max-width: 720px) { + .profile-split { + grid-template-columns: 1fr; + gap: 22px; + } + .profile-split .profile-tabs { + flex-direction: row; + flex-wrap: wrap; + border-right: 0; + border-bottom: 1px solid #e0e6dc; + padding: 0 0 16px; + } + .profile-split .profile-tabs button { + flex: 1 1 40%; + width: auto; + } +} + +.password-field { + position: relative; + display: block; + min-width: 0; + border: 1px solid #9cac9e; + border-radius: 8px; + background: #fff; +} +.password-field:focus-within { + outline: 2px solid #b9803c; + outline-offset: 3px; +} +.profile-form .password-field input { + box-sizing: border-box; + width: calc(100% - 48px); + border: 0; + background: transparent; + padding-right: 11px; + outline: none; + box-shadow: none; +} +.profile-form .password-field .password-toggle { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 8px; + border: 0; + border-radius: 7px; + background: transparent; + color: #435e46; + cursor: pointer; +} +.profile-form .password-field .password-toggle:hover { + background: #e9efe4; +} +.profile-form .password-field .password-toggle:focus-visible { + outline: 2px solid #435e46; + outline-offset: 2px; +} diff --git a/src/Profile.tsx b/src/Profile.tsx index ac3f964..46df610 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -1,37 +1,112 @@ import SiteNav from './SiteNav' import { useEffect, useState, type ChangeEvent, type FormEvent, type ComponentProps } from 'react' import './Profile.css' -import {TOKEN_KEY} from './graphql' +import { TOKEN_KEY } from './graphql' type Favorite = { id: string; name: string; number: string; createdAt: string } type TripStatus = 'PLANNED' | 'COMPLETED' -type UserTrip = Favorite & { status: TripStatus; updatedAt: string; lengthMeters: number | null; completedOn: string | null; diaryNote: string | null; rating: number | null } -function isUserTrip(item: Favorite | UserTrip): item is UserTrip { return 'status' in item } -type Municipality={code:string;name:string} -const PROFILE_FIELDS = 'username email phone avatar homeMunicipality{code name} favorites{id name number createdAt} trips{id name number status updatedAt lengthMeters completedOn diaryNote rating} goals{month target} stats{completedCount plannedCount totalLengthMeters countyCount}' -type UserProfile = { username: string; email: string | null; phone: string | null; avatar: string | null; homeMunicipality:Municipality|null; favorites: Favorite[]; trips: UserTrip[]; goals: { month: string; target: number }[]; stats: { completedCount: number; plannedCount: number; totalLengthMeters: number; countyCount: number } } +type UserTrip = Favorite & { + status: TripStatus + updatedAt: string + lengthMeters: number | null + completedOn: string | null + diaryNote: string | null + rating: number | null +} +function isUserTrip(item: Favorite | UserTrip): item is UserTrip { + return 'status' in item +} +type Municipality = { code: string; name: string } +const PROFILE_FIELDS = + 'username email phone avatar homeMunicipality{code name} favorites{id name number createdAt} trips{id name number status updatedAt lengthMeters completedOn diaryNote rating} goals{month target} stats{completedCount plannedCount totalLengthMeters countyCount}' +type UserProfile = { + username: string + email: string | null + phone: string | null + avatar: string | null + homeMunicipality: Municipality | null + favorites: Favorite[] + trips: UserTrip[] + goals: { month: string; target: number }[] + stats: { + completedCount: number + plannedCount: number + totalLengthMeters: number + countyCount: number + } +} type SelectedRoute = { id?: string; name: string; number: string } -async function graphQL(query: string, variables: Record, signal = AbortSignal.timeout(15000)) { - const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify({ query, variables }) }) +async function graphQL( + query: string, + variables: Record, + signal = AbortSignal.timeout(15000), +) { + const response = await fetch('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ query, variables }), + }) const result = await response.json().catch(() => { - throw new Error('Kunne ikke kontakte innloggingstjenesten. Kontroller at backend kjører, og prøv igjen.') + throw new Error( + 'Kunne ikke kontakte innloggingstjenesten. Kontroller at backend kjører, og prøv igjen.', + ) }) - if (!response.ok || result.errors) throw new Error(result.errors?.[0]?.message || 'Forespørselen mislyktes.') + if (!response.ok || result.errors) + throw new Error(result.errors?.[0]?.message || 'Forespørselen mislyktes.') if (!result.data) throw new Error('Innloggingstjenesten ga et ugyldig svar. Prøv igjen.') return result.data } function PasswordInput(props: Omit, 'type'>) { const [visible, setVisible] = useState(false) - return + return ( + + + + + ) } -export function AuthGate({ onAuthenticated,onSkip,onHome }: { onAuthenticated: () => void;onSkip:()=>void;onHome?:()=>void }) { - const resetToken = window.location.hash.startsWith('#reset-password/') ? window.location.hash.split('/')[1] : '' - const [mode, setMode] = useState<'login'|'register'|'forgot'|'reset'>(resetToken ? 'reset' : 'login') +export function AuthGate({ + onAuthenticated, + onSkip, + onHome, +}: { + onAuthenticated: () => void + onSkip: () => void + onHome?: () => void +}) { + const resetToken = window.location.hash.startsWith('#reset-password/') + ? window.location.hash.split('/')[1] + : '' + const [mode, setMode] = useState<'login' | 'register' | 'forgot' | 'reset'>( + resetToken ? 'reset' : 'login', + ) const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -39,72 +114,266 @@ export function AuthGate({ onAuthenticated,onSkip,onHome }: { onAuthenticated: ( const [message, setMessage] = useState('') const [error, setError] = useState(false) const [busy, setBusy] = useState(false) - const [checking, setChecking] = useState(() => !resetToken && Boolean(localStorage.getItem(TOKEN_KEY))) + const [checking, setChecking] = useState( + () => !resetToken && Boolean(localStorage.getItem(TOKEN_KEY)), + ) useEffect(() => { const token = localStorage.getItem(TOKEN_KEY) if (!token || resetToken) return const controller = new AbortController() - void graphQL('query($token:String!){me(token:$token){username}}', {token}, AbortSignal.any([controller.signal, AbortSignal.timeout(15000)])) - .then(data => { if (!controller.signal.aborted) { if(data.me) onAuthenticated(); else localStorage.removeItem(TOKEN_KEY) } }) - .catch(() => { if (!controller.signal.aborted) { setError(true); setMessage('Kunne ikke kontrollere innloggingen. Prøv igjen.') } }) - .finally(() => { if (!controller.signal.aborted) setChecking(false) }) + void graphQL( + 'query($token:String!){me(token:$token){username}}', + { token }, + AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]), + ) + .then((data) => { + if (!controller.signal.aborted) { + if (data.me) onAuthenticated() + else localStorage.removeItem(TOKEN_KEY) + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setError(true) + setMessage('Kunne ikke kontrollere innloggingen. Prøv igjen.') + } + }) + .finally(() => { + if (!controller.signal.aborted) setChecking(false) + }) return () => controller.abort() }, [onAuthenticated, resetToken]) - function changeMode(next: typeof mode) { setMode(next); setMessage(''); setError(false); setPassword(''); setConfirm('') } + function changeMode(next: typeof mode) { + setMode(next) + setMessage('') + setError(false) + setPassword('') + setConfirm('') + } async function submit(event: FormEvent) { - event.preventDefault(); setBusy(true); setMessage(''); setError(false) + event.preventDefault() + setBusy(true) + setMessage('') + setError(false) try { - if ((mode === 'register' || mode === 'reset') && password !== confirm) throw new Error('Passordene er ikke like.') + if ((mode === 'register' || mode === 'reset') && password !== confirm) + throw new Error('Passordene er ikke like.') if (mode === 'forgot') { - await graphQL('mutation($email:String!){requestPasswordReset(email:$email)}', {email:email.trim()}) - setMessage('Hvis adressen er knyttet til en profil, får den en tilbakestillingslenke. Lenken gjelder i 30 minutter.') + await graphQL('mutation($email:String!){requestPasswordReset(email:$email)}', { + email: email.trim(), + }) + setMessage( + 'Hvis adressen er knyttet til en profil, får den en tilbakestillingslenke. Lenken gjelder i 30 minutter.', + ) } else if (mode === 'reset') { - await graphQL('mutation($token:String!,$newPassword:String!){resetPassword(token:$token,newPassword:$newPassword)}', {token:resetToken,newPassword:password}) - localStorage.removeItem(TOKEN_KEY); window.history.replaceState(null,'',window.location.pathname + window.location.search + '#profile') - changeMode('login'); setMessage('Passordet er endret. Logg inn med det nye passordet.') + await graphQL( + 'mutation($token:String!,$newPassword:String!){resetPassword(token:$token,newPassword:$newPassword)}', + { token: resetToken, newPassword: password }, + ) + localStorage.removeItem(TOKEN_KEY) + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search + '#profile', + ) + changeMode('login') + setMessage('Passordet er endret. Logg inn med det nye passordet.') } else { - const query = mode === 'register' ? 'mutation($username:String!,$password:String!,$email:String!){register(username:$username,password:$password,email:$email){token}}' : 'mutation($username:String!,$password:String!){login(username:$username,password:$password){token}}' - const data = await graphQL(query, {username:username.trim(),password,...(mode==='register'?{email:email.trim()}:{})}) - localStorage.setItem(TOKEN_KEY,data[mode].token); onAuthenticated() + const query = + mode === 'register' + ? 'mutation($username:String!,$password:String!,$email:String!){register(username:$username,password:$password,email:$email){token}}' + : 'mutation($username:String!,$password:String!){login(username:$username,password:$password){token}}' + const data = await graphQL(query, { + username: username.trim(), + password, + ...(mode === 'register' ? { email: email.trim() } : {}), + }) + localStorage.setItem(TOKEN_KEY, data[mode].token) + onAuthenticated() } - } catch(reason) { setError(true); setMessage(reason instanceof Error ? reason.message : 'Noe gikk galt. Prøv igjen.') } - finally { setBusy(false) } + } catch (reason) { + setError(true) + setMessage(reason instanceof Error ? reason.message : 'Noe gikk galt. Prøv igjen.') + } finally { + setBusy(false) + } } - return
    - -
    -

    OPPDAG NORGE TIL FOTS

    {mode==='register'?'Opprett din profil.':mode==='forgot'?'Glemt passord?':mode==='reset'?'Velg nytt passord.':'Velkommen tilbake.'}

    -

    {mode==='forgot'?'Skriv inn e-postadressen på profilen din.':mode==='reset'?'Velg et nytt passord på minst 10 tegn.':mode==='register'?'Alle feltene er obligatoriske. E-post brukes til å gjenopprette tilgangen til profilen.':'Logg inn for å lagre favoritter og planlegge turer.'}

    - {checking ?

    Kontrollerer innlogging …

    : <> -
    - {(mode==='login'||mode==='register')&&} - {(mode==='register'||mode==='forgot')&&} - {mode!=='forgot'&&} - {(mode==='register'||mode==='reset')&&} - -
    - {message&&

    {message}

    } - {mode==='login'&&} - - } -
    -
    + return ( +
    + +
    +
    +
    +

    OPPDAG NORGE TIL FOTS

    +

    + {mode === 'register' + ? 'Opprett din profil.' + : mode === 'forgot' + ? 'Glemt passord?' + : mode === 'reset' + ? 'Velg nytt passord.' + : 'Velkommen tilbake.'} +

    +

    + {mode === 'forgot' + ? 'Skriv inn e-postadressen på profilen din.' + : mode === 'reset' + ? 'Velg et nytt passord på minst 10 tegn.' + : mode === 'register' + ? 'Alle feltene er obligatoriske. E-post brukes til å gjenopprette tilgangen til profilen.' + : 'Logg inn for å lagre favoritter og planlegge turer.'} +

    +
    + {checking ? ( +

    Kontrollerer innlogging …

    + ) : ( + <> +
    + {(mode === 'login' || mode === 'register') && ( + + )} + {(mode === 'register' || mode === 'forgot') && ( + + )} + {mode !== 'forgot' && ( + + )} + {(mode === 'register' || mode === 'reset') && ( + + )} + +
    + {message && ( +

    + {message} +

    + )} + {mode === 'login' && ( + + )} + + + )} +
    +
    +
    + ) } -export default function Profile({ selected, onSelectRoute, disabled, onLogout, onExplore }: { selected: SelectedRoute | null, onSelectRoute: (route: SelectedRoute) => void, disabled: boolean, onLogout?: () => void, onExplore: () => void }) { +export default function Profile({ + selected, + onSelectRoute, + disabled, + onLogout, + onExplore, +}: { + selected: SelectedRoute | null + onSelectRoute: (route: SelectedRoute) => void + disabled: boolean + onLogout?: () => void + onExplore: () => void +}) { const [user, setUser] = useState(null) const [message, setMessage] = useState('') const [busy, setBusy] = useState(false) const [loadingProfile, setLoadingProfile] = useState(true) - const [goalMonth, setGoalMonth] = useState(() => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}` }) + const [goalMonth, setGoalMonth] = useState(() => { + const now = new Date() + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` + }) const [passwordMessage, setPasswordMessage] = useState('') - const [tab, setTab] = useState<'overview'|'favorites'|'trips'|'settings'>('overview') + const [tab, setTab] = useState<'overview' | 'favorites' | 'trips' | 'settings'>('overview') useEffect(() => { const syncTab = () => { setMessage('') setPasswordMessage('') const target = window.location.hash.split('/')[1] - if (target === 'overview' || target === 'favorites' || target === 'settings' || target === 'trips') setTab(target) + if ( + target === 'overview' || + target === 'favorites' || + target === 'settings' || + target === 'trips' + ) + setTab(target) else if (target === 'planned' || target === 'completed') setTab('trips') else if (target === 'custom') setTab('favorites') } @@ -115,77 +384,134 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout, o const token = localStorage.getItem(TOKEN_KEY) - const [municipalities,setMunicipalities]=useState([]) - const loggedIn=Boolean(user) + const [municipalities, setMunicipalities] = useState([]) + const loggedIn = Boolean(user) // Kommunelisten til «Mitt sted» hentes først når brukeren er logget inn useEffect(() => { if (!loggedIn) return const controller = new AbortController() void graphQL('query{areas{municipalities{code name}}}', {}, controller.signal) - .then(data => setMunicipalities(data.areas.municipalities)) + .then((data) => setMunicipalities(data.areas.municipalities)) .catch(() => undefined) return () => controller.abort() }, [loggedIn]) useEffect(() => { if (!token || disabled) return - const controller=new AbortController() - void graphQL(`query($token:String!){me(token:$token){${PROFILE_FIELDS}}}`, { token },AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])) - .then(data => { if(controller.signal.aborted)return; setUser(data.me); if (!data.me) { localStorage.removeItem(TOKEN_KEY); onLogout?.() } }) - .catch(() => {if(!controller.signal.aborted)setMessage('Kunne ikke hente profilen. Oppdater siden for å prøve igjen.')}) - .finally(() => {if(!controller.signal.aborted)setLoadingProfile(false)}) - return ()=>controller.abort() + const controller = new AbortController() + void graphQL( + `query($token:String!){me(token:$token){${PROFILE_FIELDS}}}`, + { token }, + AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]), + ) + .then((data) => { + if (controller.signal.aborted) return + setUser(data.me) + if (!data.me) { + localStorage.removeItem(TOKEN_KEY) + onLogout?.() + } + }) + .catch(() => { + if (!controller.signal.aborted) + setMessage('Kunne ikke hente profilen. Oppdater siden for å prøve igjen.') + }) + .finally(() => { + if (!controller.signal.aborted) setLoadingProfile(false) + }) + return () => controller.abort() }, [token, disabled, onLogout]) async function toggleFavorite() { const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken || !selected?.id) return - setBusy(true); setMessage('') + setBusy(true) + setMessage('') try { - const data = await graphQL(`mutation($token:String!,$routeId:ID!){toggleFavorite(token:$token,routeId:$routeId){${PROFILE_FIELDS}}}`, { token: currentToken, routeId: selected.id }) + const data = await graphQL( + `mutation($token:String!,$routeId:ID!){toggleFavorite(token:$token,routeId:$routeId){${PROFILE_FIELDS}}}`, + { token: currentToken, routeId: selected.id }, + ) setUser(data.toggleFavorite) - } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } - finally { setBusy(false) } + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') + } finally { + setBusy(false) + } } async function setTripStatus(routeId: string, status: TripStatus | null) { const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken) return - setBusy(true); setMessage('') + setBusy(true) + setMessage('') try { - const data = await graphQL(`mutation($token:String!,$routeId:ID!,$status:TripStatus){setTripStatus(token:$token,routeId:$routeId,status:$status){${PROFILE_FIELDS}}}`, { token: currentToken, routeId, status }) + const data = await graphQL( + `mutation($token:String!,$routeId:ID!,$status:TripStatus){setTripStatus(token:$token,routeId:$routeId,status:$status){${PROFILE_FIELDS}}}`, + { token: currentToken, routeId, status }, + ) setUser(data.setTripStatus) - setMessage(status === 'COMPLETED' ? 'Turen er markert som gjennomført.' : status === 'PLANNED' ? 'Turen er lagt til planlagte turer.' : 'Turstatusen er fjernet.') - } catch (error) { setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre turstatus.') } - finally { setBusy(false) } + setMessage( + status === 'COMPLETED' + ? 'Turen er markert som gjennomført.' + : status === 'PLANNED' + ? 'Turen er lagt til planlagte turer.' + : 'Turstatusen er fjernet.', + ) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre turstatus.') + } finally { + setBusy(false) + } } async function saveDiary(routeId: string, form: HTMLFormElement, clear = false) { const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken) return const values = new FormData(form) - setBusy(true); setMessage('') + setBusy(true) + setMessage('') try { - const data = await graphQL(`mutation($token:String!,$routeId:ID!,$completedOn:String,$diaryNote:String,$rating:Int){saveTripDiary(token:$token,routeId:$routeId,completedOn:$completedOn,diaryNote:$diaryNote,rating:$rating){${PROFILE_FIELDS}}}`, { - token: currentToken, routeId, - completedOn: clear ? null : String(values.get('completedOn') || '') || null, - diaryNote: clear ? null : String(values.get('diaryNote') || ''), - rating: clear || !values.get('rating') ? null : Number(values.get('rating')), - }) - setUser(data.saveTripDiary); setMessage(clear ? 'Dagbokopplysningene er fjernet. Turen er fortsatt gjennomført.' : 'Turdagboken er lagret.') - } catch (error) { setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre dagboken.') } - finally { setBusy(false) } + const data = await graphQL( + `mutation($token:String!,$routeId:ID!,$completedOn:String,$diaryNote:String,$rating:Int){saveTripDiary(token:$token,routeId:$routeId,completedOn:$completedOn,diaryNote:$diaryNote,rating:$rating){${PROFILE_FIELDS}}}`, + { + token: currentToken, + routeId, + completedOn: clear ? null : String(values.get('completedOn') || '') || null, + diaryNote: clear ? null : String(values.get('diaryNote') || ''), + rating: clear || !values.get('rating') ? null : Number(values.get('rating')), + }, + ) + setUser(data.saveTripDiary) + setMessage( + clear + ? 'Dagbokopplysningene er fjernet. Turen er fortsatt gjennomført.' + : 'Turdagboken er lagret.', + ) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre dagboken.') + } finally { + setBusy(false) + } } async function saveGoal(target: number | null) { const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken) return - setBusy(true); setMessage('') + setBusy(true) + setMessage('') try { - const data = await graphQL(`mutation($token:String!,$month:String!,$target:Int){setMonthlyGoal(token:$token,month:$month,target:$target){${PROFILE_FIELDS}}}`, { token: currentToken, month: goalMonth, target }) - setUser(data.setMonthlyGoal); setMessage(target == null ? 'Turmålet er fjernet.' : 'Turmålet er lagret.') - } catch (error) { setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre turmålet.') } - finally { setBusy(false) } + const data = await graphQL( + `mutation($token:String!,$month:String!,$target:Int){setMonthlyGoal(token:$token,month:$month,target:$target){${PROFILE_FIELDS}}}`, + { token: currentToken, month: goalMonth, target }, + ) + setUser(data.setMonthlyGoal) + setMessage(target == null ? 'Turmålet er fjernet.' : 'Turmålet er lagret.') + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Kunne ikke lagre turmålet.') + } finally { + setBusy(false) + } } async function changePassword(event: FormEvent) { @@ -194,166 +520,643 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout, o const values = new FormData(form) const currentPassword = String(values.get('currentPassword') || '') const newPassword = String(values.get('newPassword') || '') - if (newPassword !== values.get('confirmPassword')) { setPasswordMessage('De nye passordene er ikke like.'); return } + if (newPassword !== values.get('confirmPassword')) { + setPasswordMessage('De nye passordene er ikke like.') + return + } const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken) return - setBusy(true); setPasswordMessage('') + setBusy(true) + setPasswordMessage('') try { - await graphQL('mutation($token:String!,$currentPassword:String!,$newPassword:String!){changePassword(token:$token,currentPassword:$currentPassword,newPassword:$newPassword)}', { token: currentToken, currentPassword, newPassword }) + await graphQL( + 'mutation($token:String!,$currentPassword:String!,$newPassword:String!){changePassword(token:$token,currentPassword:$currentPassword,newPassword:$newPassword)}', + { token: currentToken, currentPassword, newPassword }, + ) form.reset() localStorage.removeItem(TOKEN_KEY) - setUser(null); onLogout?.() - } catch (error) { setPasswordMessage(error instanceof Error ? error.message : 'Kunne ikke endre passordet.') } - finally { setBusy(false) } + setUser(null) + onLogout?.() + } catch (error) { + setPasswordMessage(error instanceof Error ? error.message : 'Kunne ikke endre passordet.') + } finally { + setBusy(false) + } } async function logout() { const currentToken = localStorage.getItem(TOKEN_KEY) - localStorage.removeItem(TOKEN_KEY); setUser(null); setMessage(''); onLogout?.() - if (currentToken) void graphQL('mutation($token:String!){logout(token:$token)}', { token: currentToken }).catch(() => undefined) + localStorage.removeItem(TOKEN_KEY) + setUser(null) + setMessage('') + onLogout?.() + if (currentToken) + void graphQL('mutation($token:String!){logout(token:$token)}', { token: currentToken }).catch( + () => undefined, + ) } async function saveProfile(event: FormEvent) { event.preventDefault() const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken || !user) return - setBusy(true); setMessage('') + setBusy(true) + setMessage('') const form = new FormData(event.currentTarget) try { - const data = await graphQL(`mutation($token:String!,$email:String,$phone:String,$avatar:String){updateProfile(token:$token,email:$email,phone:$phone,avatar:$avatar){${PROFILE_FIELDS}}}`, { - token: currentToken, email: String(form.get('email') || ''), phone: String(form.get('phone') || ''), avatar: user.avatar || '', - }) - setUser(data.updateProfile); setMessage('Profilen er lagret.') - } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } - finally { setBusy(false) } + const data = await graphQL( + `mutation($token:String!,$email:String,$phone:String,$avatar:String){updateProfile(token:$token,email:$email,phone:$phone,avatar:$avatar){${PROFILE_FIELDS}}}`, + { + token: currentToken, + email: String(form.get('email') || ''), + phone: String(form.get('phone') || ''), + avatar: user.avatar || '', + }, + ) + setUser(data.updateProfile) + setMessage('Profilen er lagret.') + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') + } finally { + setBusy(false) + } } async function saveHomeMunicipality(code: string) { const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken) return - setBusy(true); setMessage('') + setBusy(true) + setMessage('') try { - const data = await graphQL(`mutation($token:String!,$code:String){setHomeMunicipality(token:$token,code:$code){${PROFILE_FIELDS}}}`, { token: currentToken, code }) - setUser(data.setHomeMunicipality); setMessage('Stedet er lagret.') - } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } - finally { setBusy(false) } + const data = await graphQL( + `mutation($token:String!,$code:String){setHomeMunicipality(token:$token,code:$code){${PROFILE_FIELDS}}}`, + { token: currentToken, code }, + ) + setUser(data.setHomeMunicipality) + setMessage('Stedet er lagret.') + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') + } finally { + setBusy(false) + } } function chooseAvatar(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) return - if (!['image/png','image/jpeg','image/webp','image/gif'].includes(file.type) || file.size > 450000) { setMessage('Velg et bilde som er mindre enn 450 KB.'); return } + if ( + !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || + file.size > 450000 + ) { + setMessage('Velg et bilde som er mindre enn 450 KB.') + return + } const reader = new FileReader() - reader.onload = () => setUser(current => current ? { ...current, avatar: String(reader.result) } : current) + reader.onload = () => + setUser((current) => (current ? { ...current, avatar: String(reader.result) } : current)) reader.readAsDataURL(file) } - if (disabled) return

    Min profil

    Profil er tilgjengelig når løsningen kjøres med backend og PostgreSQL.

    - if (loadingProfile || (!user && token)) return

    Min profil

    {message || 'Henter profilen …'}

    - if (!user) return window.location.reload()} onSkip={onExplore} onHome={onExplore} /> + if (disabled) + return ( +
    +

    Min profil

    +

    Profil er tilgjengelig når løsningen kjøres med backend og PostgreSQL.

    +
    + ) + if (loadingProfile || (!user && token)) + return ( +
    +

    Min profil

    +

    {message || 'Henter profilen …'}

    + +
    + ) + if (!user) + return ( + window.location.reload()} + onSkip={onExplore} + onHome={onExplore} + /> + ) - const favorite = Boolean(selected?.id && user.favorites.some(item => item.id === selected.id)) - const goal = user.goals.find(item => item.month === goalMonth) - const monthlyCompleted = user.trips.filter(item => item.status === 'COMPLETED' && item.completedOn?.startsWith(`${goalMonth}-`)).length - const completedTrips = user.trips.filter(item => item.status === 'COMPLETED') - const hasReachedGoal = user.goals.some(item => completedTrips.filter(trip => trip.completedOn?.startsWith(`${item.month}-`)).length >= item.target) + const favorite = Boolean(selected?.id && user.favorites.some((item) => item.id === selected.id)) + const goal = user.goals.find((item) => item.month === goalMonth) + const monthlyCompleted = user.trips.filter( + (item) => item.status === 'COMPLETED' && item.completedOn?.startsWith(`${goalMonth}-`), + ).length + const completedTrips = user.trips.filter((item) => item.status === 'COMPLETED') + const hasReachedGoal = user.goals.some( + (item) => + completedTrips.filter((trip) => trip.completedOn?.startsWith(`${item.month}-`)).length >= + item.target, + ) const badges = [ - { id: 'first', symbol: '✓', title: 'Første tur', description: 'Marker én tur som gjennomført.', unlocked: completedTrips.length >= 1 }, - { id: 'five', symbol: '5', title: 'Fem turer', description: 'Gjennomfør fem forskjellige ruter.', unlocked: completedTrips.length >= 5 }, - { id: 'ten', symbol: '10', title: 'Ti turer', description: 'Gjennomfør ti forskjellige ruter.', unlocked: completedTrips.length >= 10 }, - { id: 'counties', symbol: '⌖', title: 'På tvers av fylker', description: 'Gjennomfør ruter som til sammen berører minst tre fylker.', unlocked: user.stats.countyCount >= 3 }, - { id: 'diary', symbol: '✎', title: 'Første dagboknotat', description: 'Skriv et notat i dagboken til en gjennomført tur.', unlocked: completedTrips.some(item => Boolean(item.diaryNote?.trim())) }, - { id: 'goal', symbol: '★', title: 'Månedsmål nådd', description: 'Nå et lagret turmål med daterte turer i den måneden.', unlocked: hasReachedGoal }, + { + id: 'first', + symbol: '✓', + title: 'Første tur', + description: 'Marker én tur som gjennomført.', + unlocked: completedTrips.length >= 1, + }, + { + id: 'five', + symbol: '5', + title: 'Fem turer', + description: 'Gjennomfør fem forskjellige ruter.', + unlocked: completedTrips.length >= 5, + }, + { + id: 'ten', + symbol: '10', + title: 'Ti turer', + description: 'Gjennomfør ti forskjellige ruter.', + unlocked: completedTrips.length >= 10, + }, + { + id: 'counties', + symbol: '⌖', + title: 'På tvers av fylker', + description: 'Gjennomfør ruter som til sammen berører minst tre fylker.', + unlocked: user.stats.countyCount >= 3, + }, + { + id: 'diary', + symbol: '✎', + title: 'Første dagboknotat', + description: 'Skriv et notat i dagboken til en gjennomført tur.', + unlocked: completedTrips.some((item) => Boolean(item.diaryNote?.trim())), + }, + { + id: 'goal', + symbol: '★', + title: 'Månedsmål nådd', + description: 'Nå et lagret turmål med daterte turer i den måneden.', + unlocked: hasReachedGoal, + }, ] - return
    - {user.avatar ? Profilbilde : } -

    MINE TURER

    {user.username}

    -
    - -
    - {!user.email &&

    Legg til e-post for å kunne tilbakestille passordet.

    } - {message &&

    {message}

    } - {tab === 'overview' &&
    -
    -
    {user.stats.completedCount}Gjennomførte turer
    -
    {(user.stats.totalLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} kmRegistrert rutelengde
    -
    {user.stats.countyCount}Fylker rutene berører
    -
    -

    Basert på turene du markerer som gjennomført. Lengden er registrert rutelengde, ikke målt distanse du har gått.

    -
    -

    Ditt turmål

    -
    { event.preventDefault(); const values = new FormData(event.currentTarget); void saveGoal(Number(values.get('target'))) }}> - - - - {goal && } -
    - {goal ?

    {monthlyCompleted} av {goal.target} turer · {monthlyCompleted >= goal.target ? 'Målet er nådd!' : `${goal.target-monthlyCompleted} igjen`}

    :

    Sett et mål for måneden og følg fremdriften din.

    } -

    Turer teller når de er markert gjennomført og har en dato i turdagboken i denne måneden. Hver rute telles én gang.

    -
    - {selected?.id &&

    {selected.name}

    - - - -
    } -
    -

    Dine merker

    {badges.filter(item => item.unlocked).length} av {badges.length} oppnådd
    -
      {badges.map(item =>
    • - -
      {item.title}{item.unlocked ? 'Oppnådd' : 'Ikke oppnådd ennå'}

      {item.description}

      -
    • )}
    -

    Merkene følger din nåværende turhistorikk. Endrer eller fjerner du turstatus, notater eller mål, oppdateres merkene også.

    -
    - -
    } - {tab === 'settings' &&

    Profilinnstillinger

    -
    - - {user.avatar && } - - - - -
    -

    Endre passord

    -

    Når passordet endres, logges du ut på alle enheter. Logg inn igjen med det nye passordet.

    -
    - - - - - {passwordMessage &&

    {passwordMessage}

    } -
    -
    } - {(tab === 'favorites' || tab === 'trips') &&
    -

    {tab === 'favorites' ? 'Favorittene dine' : 'Mine turer'}

    - {(tab === 'favorites' ? user.favorites : user.trips).length === 0 ?
    {tab === 'favorites' ? 'Finn en tur du vil ta vare på' : 'Planlegg din neste tur'}

    {tab === 'favorites' ? 'Åpne en rute og lagre den som favoritt.' : 'Velg en favoritt eller søk etter en rute og trykk Planlegg tur.'}

    { event.preventDefault(); onExplore() }}>Finn en tur →
    : -
      {(tab === 'favorites' ? user.favorites : user.trips).map(item =>
    • - -
      - {(!isUserTrip(item) || item.status !== 'COMPLETED') && } - {(!isUserTrip(item) || item.status !== 'PLANNED') && } - {tab !== 'favorites' && } + return ( +
      +
      +
      + {user.avatar ? ( + Profilbilde + ) : ( + + )} +
      +

      MINE TURER

      +

      {user.username}

      +
      - {tab === 'trips' && isUserTrip(item) && item.status === 'COMPLETED' &&
      Turdagbok{item.completedOn ? ` · ${new Date(`${item.completedOn}T12:00:00`).toLocaleDateString('nb-NO')}` : ''}{item.rating ? ` · ${item.rating}/5` : ''} -
      { event.preventDefault(); void saveDiary(item.id, event.currentTarget) }}> - - -