Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
60 changes: 60 additions & 0 deletions public/hero.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
294 changes: 188 additions & 106 deletions server/index.mjs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions server/sql/app/home_municipality.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Legger til kolonnen for «Mitt sted» (kommunen brukeren vil se turforslag fra på forsiden).
--
-- Må kjøres én gang av en databasebruker som eier tabellen norwegian_explorer_app.users.
-- Brukeren «gruppe» har ikke lov til å endre tabellen, så kjør den på VM-en, f.eks.:
-- sudo -u postgres psql -d norwegian_explorer -f server/sql/app/home_municipality.sql
-- Fila kan trygt kjøres flere ganger.
ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS home_municipality text;
9 changes: 7 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
import './App.css'
import './Filters.css'
import Home from './Home'
import Profile, { AuthGate } from './Profile'

maplibregl.setWorkerUrl(workerUrl)
Expand Down Expand Up @@ -118,8 +119,10 @@ function RouteMap({ route }: { route: Route }) {

export default function App() {
const [authenticated, setAuthenticated] = useState(false)
// Forsiden vises først. Innloggingssiden vises når man trykker «Logg inn».
const [showLogin, setShowLogin] = useState(false)
const handleAuthenticated = useCallback(() => setAuthenticated(true), [])
const handleLogout = useCallback(() => setAuthenticated(false), [])
const handleLogout = useCallback(() => { setAuthenticated(false); setShowLogin(false) }, [])
const detailsRef = useRef<HTMLHeadingElement>(null)
const [route, setRoute] = useState<Route | null>(null)
const [routeVariants, setRouteVariants] = useState<RouteSummary[]>([])
Expand Down Expand Up @@ -195,7 +198,9 @@ export default function App() {
void load()
return () => controller.abort()
}, [selected, attempt])
if (!authenticated) return <AuthGate onAuthenticated={handleAuthenticated} />
// Velger man en tur på forsiden, åpnes den etter innlogging
if (!authenticated && !showLogin) return <Home onLoginClick={() => setShowLogin(true)} onSelectRoute={item => { openRoute(item); setShowLogin(true) }} />
if (!authenticated) return <AuthGate onAuthenticated={handleAuthenticated} onHome={() => setShowLogin(false)} />

return <>
<a className="skip-link" href="#main">Hopp til ruteinformasjon</a>
Expand Down
144 changes: 144 additions & 0 deletions src/Home.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* Forsiden. Header-stilen kommer fra App.css. */
.home-header .header-actions {
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;
}

.visually-hidden {
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 */
}

/* 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));
}

.home-hero-content {
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);
}

.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;
}

.home-search input {
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;
}

/* Skjuler den oransje fokusrammen (fra App.css) rundt søkefeltet */
.home-search input:focus-visible {
outline: none;
}

/* ---------- Turforslag ---------- */
.home-nearby {
max-width: 1200px;
margin: 0 auto;
padding: 72px 5%;
text-align: center;
}

.home-nearby h2 {
font-size: clamp(26px, 3.5vw, 38px);
margin: 0;
}

.home-nearby-subtitle {
color: #5a6c61;
margin: 8px 0 0;
}

/* Kort med turforslag: tre i bredden, to på nettbrett og ett på mobil */
.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;
}

.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;
}

.home-suggestions button:hover { border-color: #126b51; }
.home-suggestions strong { font-size: 18px; }
.home-suggestion-length { font-size: 15px; font-weight: 600; color: #126b51; }
.home-suggestion-meta { font-size: 13px; color: #5a6c61; }

@media (max-width: 800px) {
.home-hero { min-height: 440px; }
.home-search input { font-size: 17px; padding: 18px 0; }
.home-suggestions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (max-width: 480px) {
.home-suggestions { grid-template-columns: 1fr; }
}
143 changes: 143 additions & 0 deletions src/Home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react'
import { graphql, TOKEN_KEY } from './graphql'
import './Home.css'

/**
* Lenke til bakgrunnsbildet øverst på forsiden.
* 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

type Municipality = { code: string; name: string }
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
}
type RoutePage = { routes: { total: number; items: RouteGroup[] } }
type PopularRoute = { favoriteCount: number; route: RouteVariant & { id: string; lengthMeters: number; difficulty: string | null; routeType: string | null } }

// Turer i én kommune. Samme routes-spørring som turlisten bruker, men med få treff.
const AREA_ROUTES_QUERY = `query($municipality:String!,$limit:Int!){
routes(search:"",municipality:$municipality,limit:$limit){
total items{key name variantCount minLengthMeters maxLengthMeters variants{name number difficulty routeType}}
}
}`
// De mest populære turene i hele landet, brukt når brukeren ikke har valgt sted
const POPULAR_QUERY = `query($limit:Int!){
popularRoutes(limit:$limit){ favoriteCount route{id name number lengthMeters difficulty routeType} }
}`
/** Hvor mange turforslag forsiden viser */
const SUGGESTION_COUNT = 6

/** 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 }
}

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`
}

/**
* Forsiden som vises før innlogging: header, hero med tittel og søkefelt,
* og turforslag fra stedet brukeren har valgt i profilen (eller de mest populære turene).
* Søket kobles på i et eget steg.
*/
export default function Home({ onLoginClick, onSelectRoute }: {
onLoginClick: () => void
onSelectRoute: (route: RouteVariant) => void
}) {
// Stedet velges i profilen (Profile.tsx) og lagres på brukeren i databasen. Forsiden leser det bare.
const [token] = useState(() => localStorage.getItem(TOKEN_KEY))
const [home, setHome] = useState<Municipality | null>(null)
// Svaret lagres sammen med stedet det hører til. Da kan status avledes under rendering
// i stedet for å settes i effekten (unngår ekstra renders).
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 og sorteringen skjer i databasen, ikke på klienten.
const suggestionKey = home?.code ?? ''
useEffect(() => {
const controller = new AbortController()
const request = suggestionKey
? graphql<RoutePage>(AREA_ROUTES_QUERY, { municipality: suggestionKey, limit: SUGGESTION_COUNT }, controller.signal)
.then(data => data.routes.items)
: graphql<{ popularRoutes: PopularRoute[] }>(POPULAR_QUERY, { limit: SUGGESTION_COUNT }, 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 <>
<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="login-button" onClick={onLoginClick}>Logg inn</button>
</nav>
</header>
<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()}>
<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?" autoComplete="off" maxLength={120} />
</form>
</div>
</section>

<section className="home-nearby" aria-labelledby="nearby-title">
<h2 id="nearby-title">{home ? `Velg din neste tur i ${home.name}` : 'Velg din neste tur'}</h2>
{!home && <p className="home-nearby-subtitle">De mest populære turene i Norge</p>}
{suggestionStatus === 'loading' && <p role="status">Henter turforslag …</p>}
{suggestionStatus === 'error' && <p role="alert">Kunne ikke hente turforslag. Kontroller at backend kjører.</p>}
{suggestionStatus === 'ready' && suggestions.length === 0 && <p>Fant ingen registrerte turer{home ? ` i ${home.name}` : ''}.</p>}
{suggestionStatus === 'ready' && suggestions.length > 0 && <ul className="home-suggestions">
{suggestions.map(group => {
const first = group.variants[0]
return <li key={group.key}>
<button type="button" onClick={() => onSelectRoute(first)}>
<strong>{group.name}</strong>
<span className="home-suggestion-length">{formatLength(group)}</span>
<span className="home-suggestion-meta">{[first.difficulty, first.routeType].filter(Boolean).join(' · ') || 'Turrute'}</span>
{group.favoriteCount ? <span className="home-suggestion-meta">Lagret av {group.favoriteCount} {group.favoriteCount === 1 ? 'bruker' : 'brukere'}</span> : null}
</button>
</li>
})}
</ul>}
</section>
</main>
</>
}
Loading