diff --git a/src/App.css b/src/App.css index beae444..6b1aa45 100644 --- a/src/App.css +++ b/src/App.css @@ -4,3 +4,9 @@ header{height:90px;padding:0 5%;display:flex;align-items:center;justify-content: .map-and-osm{min-width:0;background:#fff}.map-and-osm .map-panel{height:580px}.osm-panel{padding:20px;font-size:12px;border-top:1px solid #dce1d7}.osm-panel label{font-size:14px;font-weight:600;display:flex;gap:8px;align-items:center}.osm-panel input{width:18px;height:18px;accent-color:#126b51}.osm-panel p{margin:10px 0}.osm-legend{font-weight:600}.osm-panel details{margin:15px 0}.osm-panel li{margin:8px 0}.osm-panel summary{cursor:pointer}@media(max-width:800px){.map-and-osm .map-panel{height:420px;min-height:420px}} .nav-link{color:#126b51;font-weight:600;font-size:14px;text-decoration:none}.nav-link:hover{text-decoration:underline}.header-actions{display:flex;align-items:center;gap:12px}.profile-link{display:flex;align-items:center;gap:8px;text-decoration:none;background:#126b51;color:#fff;padding:9px 14px;border-radius:20px;font-size:12px;font-weight:700}.profile-link span{font-size:15px} + +.explore-search-bar { margin: 24px 0; } +.explore-search-form { display: flex; align-items: center; gap: 12px; padding: 8px 12px 8px 18px; border: 1px solid #d8dfd5; border-radius: 10px; background: #fff; } +.explore-search-form input { flex: 1; min-width: 0; border: 0; background: transparent; box-shadow: none; padding: 12px 0; } +.explore-search-form button { flex-shrink: 0; } +@media (max-width: 480px) { .explore-search-form { gap: 8px; padding-left: 12px; } .explore-search-form > span, .explore-search-form button span { display: none; } } diff --git a/src/App.tsx b/src/App.tsx index e786abb..21d0cf0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import SiteNav from './SiteNav' import { useCallback, useEffect, useRef, useState } from 'react' import * as maplibregl from 'maplibre-gl' import type { FeatureCollection, LineString, MultiLineString } from 'geojson' @@ -135,10 +136,10 @@ function RouteMap({ route }: { route: Route }) { } export default function App() { - const [view,setView] = useState<'explore'|'profile'>('explore') - const [showHome,setShowHome]=useState(true) + 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'))) const [showLogin,setShowLogin]=useState(false) - const [guest,setGuest]=useState(false) + 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)}, []) @@ -233,13 +234,11 @@ export default function App() { return
Hopp til ruteinformasjon -
△ Norwegian Explorer
+ 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'})}} />
diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx index fc86591..50f72b7 100644 --- a/src/Fjellomrade.tsx +++ b/src/Fjellomrade.tsx @@ -1,3 +1,5 @@ +import SiteNav from './SiteNav' +import { TOKEN_KEY } from './graphql' // Fjellområdesiden (fjellomrade.html): først en oversikt med ett kort per fjellområde. Klikker man på et kort, // åpnes kartet over hele skjermen med et panel oppå, som viser hyttene, buene, gapahukene og toppene i området. // Områdene kommer fra silver.mountain_areas og punktene fra silver.osm_points_of_interest. @@ -169,6 +171,28 @@ function readHash() { } export default function Fjellomrade() { + const [authenticated, setAuthenticated] = useState(false) + useEffect(() => { + let controller: AbortController | undefined + const checkSession = () => { + controller?.abort() + 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 (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) }) + } + checkSession() + window.addEventListener('storage', checkSession) + window.addEventListener('focus', checkSession) + return () => { controller?.abort(); window.removeEventListener('storage', checkSession); window.removeEventListener('focus', checkSession) } + }, []) const [areas, setAreas] = useState(null) const [error, setError] = useState('') const [route, setRoute] = useState(readHash) @@ -190,14 +214,14 @@ export default function Fjellomrade() { if (area) return return ( - { overviewScroll.current = window.scrollY }} /> ) } -type OverviewProps = { 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({ areas, error, notFound, onOpen }: OverviewProps) { +function Overview({ authenticated, areas, error, notFound, onOpen }: OverviewProps) { const [region, setRegion] = useState('all') const [query, setQuery] = useState('') const needle = query.trim().toLowerCase() @@ -223,12 +247,7 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) { return (
- +

Fjellområder i Norge

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

diff --git a/src/Home.css b/src/Home.css index 5034b38..7c63e10 100644 --- a/src/Home.css +++ b/src/Home.css @@ -239,3 +239,29 @@ .home-nearby-reset { 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}} diff --git a/src/Home.tsx b/src/Home.tsx index 1521366..9830b58 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,3 +1,4 @@ +import SiteNav from './SiteNav' import { useEffect, useRef, useState } from 'react' import { graphql, TOKEN_KEY } from './graphql' import './Home.css' @@ -7,9 +8,9 @@ import './Home.css' * Lim inn URL-en til bildet her (eller legg bildet i public/ og skriv f.eks. '/hero.jpg'). * Er feltet tomt, vises en grønn bakgrunn i stedet. */ -const HERO_IMAGE_URL = `${import.meta.env.BASE_URL}hero.svg` // egen illustrasjon i public/hero.svg +const HERO_IMAGE_URL = `${import.meta.env.BASE_URL}images/explore-nature.jpg` /** Kreditering av fotografen, vises nederst i hjørnet av bildet. La stå tom hvis ikke nødvendig. */ -const HERO_IMAGE_CREDIT = '' +const HERO_IMAGE_CREDIT = 'Generert stemningsbilde' /** Hvor lenge vi venter etter siste tastetrykk før vi søker (debouncing, sparer requests) */ const SEARCH_DELAY_MS = 300 @@ -146,13 +147,7 @@ export default function Home({ onLoginClick, onSelectRoute,authenticated=false,o } return <> -
- △ Norwegian Explorer - -
+ window.scrollTo({top:0,behavior:'smooth'})} onExplore={onExploreClick} onProfile={authenticated?onProfileClick:onLoginClick} />
-

Ut på tur, aldri sur

-
event.preventDefault()}> +

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.'} diff --git a/src/Profile.css b/src/Profile.css index 18e7420..4cfb2d9 100644 --- a/src/Profile.css +++ b/src/Profile.css @@ -237,3 +237,10 @@ .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; } } diff --git a/src/Profile.tsx b/src/Profile.tsx index d4bc688..732086b 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -1,7 +1,7 @@ +import SiteNav from './SiteNav' import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react' import './Profile.css' import {TOKEN_KEY} from './graphql' -import RouteBuilder from './RouteBuilder' type Favorite = { id: string; name: string; number: string; createdAt: string } type TripStatus = 'PLANNED' | 'COMPLETED' @@ -53,7 +53,7 @@ export function AuthGate({ onAuthenticated,onSkip,onHome }: { onAuthenticated: ( } return

-
{onHome?:△ Norwegian Explorer}Fjellområder
+

OPPDAG NORGE TIL FOTS

{checking ? 'Velkommen tilbake.' : mode === 'login' ? 'Logg inn for å utforske.' : 'Opprett din profil.'}

Lagre favoritter og finn registrerte turruter fra hele Norge.

@@ -72,7 +72,7 @@ export function AuthGate({ onAuthenticated,onSkip,onHome }: { onAuthenticated: (
} -export default function Profile({ selected, onSelectRoute, disabled, onLogout }: { selected: SelectedRoute | null, onSelectRoute: (route: SelectedRoute) => void, disabled: boolean, onLogout?: () => 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 [mode, setMode] = useState<'login'|'register'>('login') const [username, setUsername] = useState('') @@ -82,7 +82,21 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: const [loadingProfile, setLoadingProfile] = useState(true) 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<'favorites'|'planned'|'completed'|'settings'|'custom'>('favorites') + 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) + else if (target === 'planned' || target === 'completed') setTab('trips') + else if (target === 'custom') setTab('favorites') + } + syncTab() + window.addEventListener('hashchange', syncTab) + return () => window.removeEventListener('hashchange', syncTab) + }, []) + const token = localStorage.getItem(TOKEN_KEY) const [municipalities,setMunicipalities]=useState([]) @@ -256,6 +270,13 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: return
{user.avatar ? Profilbilde : }

MINE TURER

{user.username}

+
+ +
+ {message &&

{message}

} + {tab === 'overview' &&
{user.stats.completedCount}Gjennomførte turer
{(user.stats.totalLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} kmRegistrert rutelengde
@@ -278,7 +299,6 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }:
} - {message &&

{message}

}

Dine merker

{badges.filter(item => item.unlocked).length} av {badges.length} oppnådd
    {badges.map(item =>
  • @@ -287,9 +307,8 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }:
  • )}

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

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

Profilinnstillinger

@@ -314,18 +333,17 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }: {passwordMessage &&

{passwordMessage}

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

{tab === 'favorites' ? 'Favorittene dine' : tab === 'planned' ? 'Neste turer' : 'Turene du har gjennomført'}

- {(tab === 'favorites' ? user.favorites : user.trips.filter(t => t.status === (tab === 'planned' ? 'PLANNED' : 'COMPLETED'))).length === 0 ?
{tab === 'favorites' ? 'Finn en tur du vil ta vare på' : tab === 'planned' ? 'Hvor går neste tur?' : 'Din turhistorikk starter her'}

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

: -
    {(tab === 'favorites' ? user.favorites : user.trips.filter(t => t.status === (tab === 'planned' ? 'PLANNED' : 'COMPLETED'))).map(item =>
  • - + {(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 =>
    • +
      - {tab !== 'completed' && } - {tab !== 'planned' && } + {(!isUserTrip(item) || item.status !== 'COMPLETED') && } + {(!isUserTrip(item) || item.status !== 'PLANNED') && } {tab !== 'favorites' && }
      - {tab === 'completed' && isUserTrip(item) &&
      Turdagbok{item.completedOn ? ` · ${new Date(`${item.completedOn}T12:00:00`).toLocaleDateString('nb-NO')}` : ''}{item.rating ? ` · ${item.rating}/5` : ''} + {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) }}> @@ -336,5 +354,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }:
      }
    • )}
    }
    } +
+
} diff --git a/src/SiteNav.css b/src/SiteNav.css new file mode 100644 index 0000000..6810ca2 --- /dev/null +++ b/src/SiteNav.css @@ -0,0 +1,16 @@ +.site-header.site-header { box-sizing: border-box; min-height: 86px; height: auto; padding: 18px 5%; display: flex; align-items: center; justify-content: space-between; gap: 20px; background: #f8faf6; border-bottom: 1px solid #e0e6dc; font-family: 'Avenir Next', system-ui, sans-serif; } +.site-header .site-brand { display: flex; align-items: center; gap: 10px; color: #233b2d; font-size: 22px; font-weight: 750; text-decoration: none; } +.site-header .site-brand span { font-size: 19px; } +.site-header .site-nav { display: flex; align-items: center; gap: 28px; } +.site-header .site-nav a { display: inline-flex; align-items: center; gap: 9px; color: #788274; font-size: 14px; font-weight: 650; text-decoration: none; padding: 12px 0; } +.site-header .site-nav a:hover, .site-header .site-nav a[aria-current=page] { color: #233b2d; } +.site-header .site-nav .site-profile { background: #e9efe4; color: #435e46; padding: 14px 20px; border-radius: 12px; } +.site-header a:focus-visible { outline: 2px solid #435e46; outline-offset: 4px; } +@media(max-width: 800px) { .site-header.site-header { flex-wrap: wrap; padding: 16px 20px; gap: 12px; } .site-header .site-nav { width: 100%; gap: 18px; flex-wrap: wrap; } .site-header .site-nav a { font-size: 12px; } .site-header .site-nav .site-profile { padding: 10px 12px; } } + +.site-profile-menu { position: relative; } +.site-header .site-profile-menu > button { display: inline-flex; align-items: center; gap: 9px; font: inherit; font-size: 14px; font-weight: 650; border: 0; cursor: pointer; } +.site-dropdown { position: absolute; right: 0; top: calc(100% + 8px); z-index: 100; min-width: 225px; padding: 8px; background: #fff; border: 1px solid #e0e6dc; border-radius: 12px; box-shadow: 0 10px 35px #233b2d20; } +.site-header .site-dropdown a { display: flex; padding: 11px 14px; border-radius: 7px; color: #233b2d; } +.site-header .site-dropdown a:hover { background: #edf2e9; } +.site-header button:focus-visible { outline: 2px solid #435e46; outline-offset: 4px; } diff --git a/src/SiteNav.tsx b/src/SiteNav.tsx new file mode 100644 index 0000000..f505d5f --- /dev/null +++ b/src/SiteNav.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef, useState } from 'react' +import './SiteNav.css' + +type Props = { active?: 'home' | 'explore' | 'mountains' | 'profile'; authenticated: boolean; onHome?: () => void; onExplore?: () => void; onProfile?: () => void } +export default function SiteNav({ active, authenticated, onHome, onExplore, onProfile }: Props) { + const [open, setOpen] = useState(false) + const menuRef = useRef(null) + useEffect(() => { + if (!open) return + const close = (event: PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setOpen(false) } + const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpen(false); menuRef.current?.querySelector('button')?.focus() } } + document.addEventListener('pointerdown', close) + document.addEventListener('keydown', escape) + return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', escape) } + }, [open]) + const base = import.meta.env.BASE_URL + return
{ event.preventDefault(); onHome() } : undefined}>△ Norwegian Explorer
+}