Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/App.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 8 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)}, [])
Expand Down Expand Up @@ -233,13 +234,11 @@ export default function App() {

return <div className="explore-app">
<a className="skip-link" href="#main">Hopp til ruteinformasjon</a>
<header><a className="brand" href={import.meta.env.BASE_URL}>△ <span>Norwegian Explorer</span></a><nav className="header-actions" aria-label="Brukermeny"><button className="nav-tab" onClick={()=>setShowHome(true)}>Forside</button><button className="nav-tab" aria-pressed={view==='explore'} onClick={()=>{setView('explore');window.scrollTo({top:0,behavior:'smooth'})}}>Utforsk turer</button><a className="nav-link" href={`${import.meta.env.BASE_URL}fjellomrade.html`}>Fjellområder</a><button className="profile-link" aria-pressed={view==='profile'} onClick={()=>{if(!authenticated){setGuest(false);setShowLogin(true);}setView('profile');window.scrollTo({top:0,behavior:'smooth'})}}><span aria-hidden="true">◉</span> {authenticated?'Min profil':'Logg inn'}</button></nav></header>
<SiteNav active={view} authenticated={authenticated} onHome={()=>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'})}} />
<main id="main">
<div hidden={view!=='explore'}>
<section className="explore-hero" aria-labelledby="explore-title">
<div className="hero-copy"><p className="eyebrow">DITT NESTE PUSTEROM</p><h1 id="explore-title">Finn veien<br/>til litt mer ute.</h1><p>Små avbrekk. Lange eventyr.<br/>Utforsk turer og finn din egen vei.</p>
<form className="hero-search" onSubmit={event=>{event.preventDefault();setPage(0);setSearch(input.trim());setListAttempt(n=>n+1);setListStatus('loading');document.getElementById('route-results')?.scrollIntoView({behavior:'smooth'})}}><label className="visually-hidden" htmlFor="route-search">Søk etter rutenavn</label><span aria-hidden="true">⌕</span><input id="route-search" value={input} maxLength={120} onChange={event=>setInput(event.target.value)} placeholder="Hvor vil du gå? Søk etter en rute"/><button type="submit">Finn turer <span aria-hidden="true">→</span></button></form>
</div><span className="hero-image-note">Generert stemningsbilde</span>
<section className="explore-search-bar" aria-label="Søk etter turer">
<form className="explore-search-form" onSubmit={event=>{event.preventDefault();setPage(0);setSearch(input.trim());setListAttempt(n=>n+1);setListStatus('loading');document.getElementById('route-results')?.scrollIntoView({behavior:'smooth'})}}><label className="visually-hidden" htmlFor="route-search">Søk etter rutenavn</label><span aria-hidden="true">⌕</span><input id="route-search" value={input} maxLength={120} onChange={event=>setInput(event.target.value)} placeholder="Hvor vil du gå? Søk etter en rute"/><button type="submit">Finn turer <span aria-hidden="true">→</span></button></form>
</section>
<section className="explore-areas" aria-labelledby="area-title"><div className="section-title"><div><p className="eyebrow">NÆRT ELLER LITT LENGER UNNA</p><h2 id="area-title">Hvor vil du utforske?</h2></div><a href="#filters" onClick={()=>{const filters=document.getElementById('filters') as HTMLDetailsElement|null;if(filters)filters.open=true}}>Alle områder <span aria-hidden="true">↗</span></a></div>
<div className="area-cards">{counties.filter(item=>['Trøndelag','Vestland','Nordland','Innlandet'].some(name=>item.name.startsWith(name))).map((item,index)=><button className="area-card" key={item.code} aria-pressed={county===item.code} onClick={()=>{setCounty(item.code);setMunicipality(null);setPage(0);setListStatus('loading');document.getElementById('route-results')?.scrollIntoView({behavior:'smooth'})}}><span className="area-symbol" aria-hidden="true">{['△','≋','⋀','♧'][index%4]}</span><span className="area-name">{item.name.split(' - ')[0]}</span><span className="area-count">{item.routeCount.toLocaleString('nb-NO')} registrerte ruter</span><span className="area-arrow" aria-hidden="true">↗</span></button>)}</div>
Expand Down Expand Up @@ -294,7 +293,7 @@ export default function App() {
{status === 'ready' && route && <ElevationProfile geojson={route.geojson} />}
</div>
<div hidden={view!=='profile'}><div className="profile-page-title"><p className="eyebrow">DITT EGET TURUNIVERS</p><h1>Min profil.</h1><p>Favorittene dine, turene dine og neste eventyr.</p></div>
{authenticated&&<Profile selected={status==='ready'&&route ? { id: route.id, name: route.name, number: route.number } : selected} disabled={false} onSelectRoute={openRoute} onLogout={handleLogout} />}
{authenticated&&<Profile selected={status==='ready'&&route ? { id: route.id, name: route.name, number: route.number } : selected} disabled={false} onExplore={()=>{setView('explore');window.location.hash='explore';requestAnimationFrame(()=>{const input=document.getElementById('route-search');input?.scrollIntoView({behavior:'smooth',block:'center'});input?.focus({preventScroll:true})})}} onSelectRoute={openRoute} onLogout={handleLogout} />}
</div>
<footer><>Rutedata: <a href="https://kartverket.no/api-og-data/friluftsliv">Kartverket</a> · <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. Rutelinjen er bearbeidet fra originale rutedeler.</></footer>
</main>
Expand Down
37 changes: 28 additions & 9 deletions src/Fjellomrade.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<MountainArea[] | null>(null)
const [error, setError] = useState('')
const [route, setRoute] = useState(readHash)
Expand All @@ -190,14 +214,14 @@ export default function Fjellomrade() {

if (area) return <AreaView key={area.id} area={area} pointId={route.pointId} />
return (
<Overview areas={areas} error={error} notFound={areas && route.areaId ? route.areaId : null}
<Overview authenticated={authenticated} areas={areas} error={error} notFound={areas && route.areaId ? route.areaId : null}
onOpen={() => { 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<Region | 'all'>('all')
const [query, setQuery] = useState('')
const needle = query.trim().toLowerCase()
Expand All @@ -223,12 +247,7 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) {

return (
<div className="fo-page">
<nav className="fo-top-bar">
<a href={import.meta.env.BASE_URL} className="fo-brand">Utforsk Norge</a>
<a href="#/" className="is-on">Fjellområder</a>
{/* Profilen ligger på hovedsiden (index.html), i seksjonen med id="profile" */}
<a href={`${import.meta.env.BASE_URL}#profile`} className="fo-profile-link">Min profil</a>
</nav>
<SiteNav active="mountains" authenticated={authenticated} />
<header className="fo-hero">
<h1>Fjellområder i Norge</h1>
<p>Velg et fjellområde for å åpne kartet, med alle hyttene, buene, gapahukene og toppene på ett sted.</p>
Expand Down
26 changes: 26 additions & 0 deletions src/Home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
24 changes: 11 additions & 13 deletions src/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SiteNav from './SiteNav'
import { useEffect, useRef, useState } from 'react'
import { graphql, TOKEN_KEY } from './graphql'
import './Home.css'
Expand All @@ -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
Expand Down Expand Up @@ -146,36 +147,33 @@ export default function Home({ onLoginClick, onSelectRoute,authenticated=false,o
}

return <>
<header className="home-header">
<span className="brand">△ <span>Norwegian Explorer</span></span>
<nav className="header-actions" aria-label="Hovedmeny">
<a className="nav-link" href={`${import.meta.env.BASE_URL}fjellomrade.html`}>Fjellområder</a>
<button type="button" className="nav-link" onClick={onExploreClick}>Utforsk turer</button><button type="button" className="login-button" onClick={authenticated?onProfileClick:onLoginClick}>{authenticated?'Min profil':'Logg inn'}</button>
</nav>
</header>
<SiteNav active="home" authenticated={authenticated} onHome={()=>window.scrollTo({top:0,behavior:'smooth'})} onExplore={onExploreClick} onProfile={authenticated?onProfileClick:onLoginClick} />
<main id="main" className="home-main">
<section
className="home-hero"
aria-labelledby="home-title"
style={HERO_IMAGE_URL ? { backgroundImage: `url("${HERO_IMAGE_URL}")` } : undefined}
>
<div className="home-hero-content">
<h1 id="home-title">Ut på tur, aldri sur</h1>
<form className="home-search" role="search" onSubmit={event => event.preventDefault()}>
<p className="home-eyebrow">DITT NESTE PUSTEROM</p>
<h1 id="home-title">Finn veien<br />til litt mer ute.</h1>
<p className="home-intro">Små avbrekk. Lange eventyr.<br />Utforsk turer og finn din egen vei.</p>
<form className="home-search" role="search" onSubmit={event => {event.preventDefault();if(trimmed.length<2)document.getElementById('home-search-input')?.focus();else document.getElementById('home-search-results')?.scrollIntoView({behavior:'smooth',block:'nearest'})}}>
<label htmlFor="home-search-input" className="visually-hidden">Søk etter turer</label>
<svg aria-hidden="true" viewBox="0 0 24 24" width="24" height="24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.5" /><path d="m15.5 15.5 5 5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
<input
id="home-search-input"
type="search"
placeholder="Hvor vil du på tur?"
placeholder="Hvor vil du gå? Søk etter en rute eller et sted"
autoComplete="off"
maxLength={120}
value={input}
onChange={event => setInput(event.target.value)}
aria-describedby="home-search-status"
/>
<button type="submit" className="home-find-button">Finn turer <span aria-hidden="true">→</span></button>
</form>
<div className="home-search-results">
<div className="home-search-results" id="home-search-results">
<p id="home-search-status" role="status" className={searchStatus === 'ready' ? '' : 'visually-hidden'}>
{searchStatus === 'loading' && 'Søker …'}
{searchStatus === 'error' && 'Søket feilet. Kontroller at backend kjører.'}
Expand Down
7 changes: 7 additions & 0 deletions src/Profile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; } }
Loading