diff --git a/client/src/App.css b/client/src/App.css index 3f5c2af..cead6c1 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -170,6 +170,68 @@ margin: 0; } +/* Felles utseende for runde og avlange knapper over kartet. */ +.map-control { + display: inline-grid; + place-items: center; + border: 2px solid var(--navy-900); + background: var(--surface); + color: var(--navy-900); + font: inherit; + font-weight: 700; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + transform 0.15s, + box-shadow 0.15s; +} + +.map-control:hover:not(:disabled) { + background: var(--navy-900); + color: var(--eggshell); + transform: translateY(-2px); + box-shadow: 0 0.35rem 0.75rem rgb(10 34 64 / 0.25); +} + +.map-control:active:not(:disabled) { + transform: translateY(0); + box-shadow: none; +} + +.home-link { + position: absolute; + top: 0.5rem; + left: 3.75rem; + z-index: 3; + grid-auto-flow: column; + gap: 0.5rem; + height: 2.75rem; + padding-inline: 1rem; + border-radius: 999px; + text-decoration: none; +} + +.home-link svg { + width: 1.25rem; + height: 1.25rem; +} + +.map-control:disabled { + opacity: 0.4; + cursor: default; +} + +/* På den mørke headeren skal knappene ha lys kant og lys utheving. */ +.site-header .map-control { + border-color: var(--blue-300); +} + +.site-header .map-control:hover:not(:disabled) { + background: var(--blue-300); + color: var(--navy-900); +} + /* ---------- Navigasjon ---------- */ .main-nav ul { diff --git a/client/src/App.tsx b/client/src/App.tsx index cf31d54..4dac9c2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,11 +2,14 @@ import './App.css' import { useRoute } from './hooks/useRoute' import { HomePage } from './pages/HomePage' import { MapPage } from './pages/MapPage' +import { UserPage } from './pages/UserPage' + +const pages = { home: HomePage, map: MapPage, user: UserPage } function App() { - const route = useRoute() + const Page = pages[useRoute()] - return route === 'map' ? : + return } export default App diff --git a/client/src/api/user.ts b/client/src/api/user.ts new file mode 100644 index 0000000..55373c6 --- /dev/null +++ b/client/src/api/user.ts @@ -0,0 +1,17 @@ +import { mockUser } from '../data/mockUser' +import type { User } from '../types/user' + +/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */ +const LATENCY_MS = 200 + +/** + * Henter den innloggede brukeren. + * + * Når innlogging og GraphQL-backenden er på plass, byttes innholdet her ut med + * et kall mot serveren (med samme signatur). + */ +export async function fetchCurrentUser(): Promise { + await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)) + + return mockUser +} diff --git a/client/src/components/Gulls.tsx b/client/src/components/Gulls.tsx new file mode 100644 index 0000000..67abcb3 --- /dev/null +++ b/client/src/components/Gulls.tsx @@ -0,0 +1,35 @@ +/** Dekor: noen måker over havet, én gruppe på hver side av tittelen. */ +export function Gulls() { + return ( + <> + + + + ) +} diff --git a/client/src/components/HomeLink.tsx b/client/src/components/HomeLink.tsx new file mode 100644 index 0000000..cecb09d --- /dev/null +++ b/client/src/components/HomeLink.tsx @@ -0,0 +1,23 @@ +import { HOME_HREF } from '../hooks/useRoute' + +/** Knappen øverst til venstre som tar deg tilbake til hovedsiden. */ +export function HomeLink() { + return ( + + + Hovedside + + ) +} diff --git a/client/src/components/MainNav.tsx b/client/src/components/MainNav.tsx index 5db9044..4155d32 100644 --- a/client/src/components/MainNav.tsx +++ b/client/src/components/MainNav.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { HOME_HREF, MAP_HREF, USER_HREF } from '../hooks/useRoute' /** Felles tegning for ikonene: små, enkle strek-ikoner. */ function Icon({ children }: { children: ReactNode }) { @@ -28,7 +29,7 @@ const links: { }[] = [ { label: 'Utforsk', - href: '#/kart', + href: MAP_HREF, // Kompass icon: ( @@ -50,7 +51,7 @@ const links: { }, { label: 'Hjem', - href: '#/', + href: HOME_HREF, current: true, // Anker icon: ( @@ -71,6 +72,7 @@ const links: { }, { label: 'Bruker', + href: USER_HREF, // Person icon: ( diff --git a/client/src/components/SiteHeader.tsx b/client/src/components/SiteHeader.tsx index 13acb0a..f20b017 100644 --- a/client/src/components/SiteHeader.tsx +++ b/client/src/components/SiteHeader.tsx @@ -1,3 +1,4 @@ +import { Gulls } from './Gulls' import { HelpButton } from './HelpButton' import { MainNav } from './MainNav' import { MenuButton } from './MenuButton' @@ -17,35 +18,7 @@ export function SiteHeader() { skjule dem igjen. Under «Utforsk» ser du stedene på et kart. - {/* Dekor: noen måker over havet, én gruppe på hver side av tittelen. */} - - +

diff --git a/client/src/components/user/Avatar.tsx b/client/src/components/user/Avatar.tsx new file mode 100644 index 0000000..9de3d83 --- /dev/null +++ b/client/src/components/user/Avatar.tsx @@ -0,0 +1,27 @@ +type AvatarProps = { + name: string + url: string | null +} + +/** Profilbildet, eller en enkel plassholder når bildet mangler. */ +export function Avatar({ name, url }: AvatarProps) { + if (url) { + return {`Profilbilde + } + + return ( + + ) +} diff --git a/client/src/components/user/BadgeSlots.tsx b/client/src/components/user/BadgeSlots.tsx new file mode 100644 index 0000000..4cb09b5 --- /dev/null +++ b/client/src/components/user/BadgeSlots.tsx @@ -0,0 +1,21 @@ +const SLOTS = [0, 1, 2, 3, 4] + +/** Tomme plasser for badges. Selve badgene kommer når utfordringene er laget. */ +export function BadgeSlots() { + return ( + <> + +

+ Badges får du ved å fullføre utfordringer. De dukker opp her. +

+ + ) +} diff --git a/client/src/components/user/CarouselSection.tsx b/client/src/components/user/CarouselSection.tsx new file mode 100644 index 0000000..e400b9d --- /dev/null +++ b/client/src/components/user/CarouselSection.tsx @@ -0,0 +1,130 @@ +import { Children, useCallback, useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { ProfileSection } from './ProfileSection' + +type CarouselSectionProps = { + id: string + title: string + icon: ReactNode + /** Vises når seksjonen ikke har noe innhold ennå. */ + emptyText: string + /** Ett `
  • `-element per kort. */ + children: ReactNode +} + +function Chevron({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +const prefersReducedMotion = () => + window.matchMedia('(prefers-reduced-motion: reduce)').matches + +/** + * En seksjon med kort på én rad. Er det flere kort enn det er plass til, vises + * piler, og raden kan også dras eller rulles sidelengs. + */ +export function CarouselSection({ + id, + title, + icon, + emptyText, + children, +}: CarouselSectionProps) { + const listRef = useRef(null) + const [scroll, setScroll] = useState({ + hasOverflow: false, + atStart: true, + atEnd: true, + }) + const count = Children.count(children) + + const update = useCallback(() => { + const list = listRef.current + if (!list) return + + setScroll({ + hasOverflow: list.scrollWidth > list.clientWidth + 1, + atStart: list.scrollLeft <= 1, + atEnd: list.scrollLeft + list.clientWidth >= list.scrollWidth - 1, + }) + }, []) + + useEffect(() => { + const list = listRef.current + if (!list) return + + update() + const observer = new ResizeObserver(update) + observer.observe(list) + return () => observer.disconnect() + }, [update, count]) + + const scrollByPage = (direction: -1 | 1) => { + const list = listRef.current + list?.scrollBy({ + left: direction * list.clientWidth, + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }) + } + + const controls = scroll.hasOverflow && ( + +
  • + +
  • +
  • + +
  • +
    + ) + + return ( + + {count === 0 ? ( +

    {emptyText}

    + ) : ( + // Fokuserbar, så raden kan rulles med piltastene uten mus. +
      + {children} +
    + )} +
    + ) +} diff --git a/client/src/components/user/FavouritePlaceCard.tsx b/client/src/components/user/FavouritePlaceCard.tsx new file mode 100644 index 0000000..e35f1cf --- /dev/null +++ b/client/src/components/user/FavouritePlaceCard.tsx @@ -0,0 +1,23 @@ +import type { Place } from '../../types/place' +import { PlaceImage } from '../PlaceImage' + +/** Et lite kort for et favorittsted. */ +export function FavouritePlaceCard({ place }: { place: Place }) { + return ( +
  • +
    +

    {place.name}

    +

    + {place.type} · {place.area} +

    +

    + Vurdering: + {place.rating.toFixed(1)} +

    +
    + +
    +
    +
  • + ) +} diff --git a/client/src/components/user/ProfileSection.tsx b/client/src/components/user/ProfileSection.tsx new file mode 100644 index 0000000..08238e9 --- /dev/null +++ b/client/src/components/user/ProfileSection.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' + +type ProfileSectionProps = { + id: string + title: string + icon: ReactNode + /** Knapper til høyre for overskriften. */ + actions?: ReactNode + children: ReactNode +} + +/** En seksjon på profilsiden, med ikon og overskrift. */ +export function ProfileSection({ + id, + title, + icon, + actions, + children, +}: ProfileSectionProps) { + return ( +
    +
    +

    + {icon} + {title} +

    + {actions} +
    + {children} +
    + ) +} diff --git a/client/src/data/mockUser.ts b/client/src/data/mockUser.ts new file mode 100644 index 0000000..b44a2bb --- /dev/null +++ b/client/src/data/mockUser.ts @@ -0,0 +1,10 @@ +import type { User } from '../types/user' +import { mockPlaces } from './mockPlaces' + +/** Eksempelbruker. Erstattes av den innloggede brukeren fra backend senere. */ +export const mockUser: User = { + id: 'ola-normann', + name: 'Ola Normann', + avatarUrl: null, + favouritePlaces: mockPlaces.slice(0, 7), +} diff --git a/client/src/hooks/useCurrentUser.ts b/client/src/hooks/useCurrentUser.ts new file mode 100644 index 0000000..14da164 --- /dev/null +++ b/client/src/hooks/useCurrentUser.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' +import { fetchCurrentUser } from '../api/user' +import type { User } from '../types/user' + +type Status = 'loading' | 'ready' | 'error' + +/** Henter brukeren som vises på profilsiden. */ +export function useCurrentUser() { + const [user, setUser] = useState(null) + const [status, setStatus] = useState('loading') + + useEffect(() => { + let isCurrent = true + + fetchCurrentUser() + .then((result) => { + if (!isCurrent) return + setUser(result) + setStatus('ready') + }) + .catch(() => { + if (isCurrent) setStatus('error') + }) + + return () => { + isCurrent = false + } + }, []) + + return { user, status } +} diff --git a/client/src/hooks/useRoute.ts b/client/src/hooks/useRoute.ts index acd49a1..7039a67 100644 --- a/client/src/hooks/useRoute.ts +++ b/client/src/hooks/useRoute.ts @@ -1,18 +1,27 @@ import { useSyncExternalStore } from 'react' -export type Route = 'home' | 'map' +export type Route = 'home' | 'map' | 'user' /** Adressene i nettleseren. Hash-adresser trenger ingen serverkonfigurasjon. */ export const HOME_HREF = '#/' export const MAP_HREF = '#/kart' +export const USER_HREF = '#/bruker' function subscribe(onChange: () => void) { window.addEventListener('hashchange', onChange) return () => window.removeEventListener('hashchange', onChange) } -const getRoute = (): Route => - window.location.hash === MAP_HREF ? 'map' : 'home' +function getRoute(): Route { + switch (window.location.hash) { + case MAP_HREF: + return 'map' + case USER_HREF: + return 'user' + default: + return 'home' + } +} /** Hvilken side som vises, styrt av adressen. */ export function useRoute(): Route { diff --git a/client/src/pages/MapPage.css b/client/src/pages/MapPage.css index e5e2e80..7ea3756 100644 --- a/client/src/pages/MapPage.css +++ b/client/src/pages/MapPage.css @@ -20,53 +20,6 @@ background: var(--blue-300); } -/* Felles utseende for runde og avlange knapper over kartet. */ -.map-control { - display: inline-grid; - place-items: center; - border: 2px solid var(--navy-900); - background: var(--surface); - color: var(--navy-900); - font: inherit; - font-weight: 700; - cursor: pointer; - transition: - background 0.15s, - color 0.15s, - transform 0.15s, - box-shadow 0.15s; -} - -.map-control:hover { - background: var(--navy-900); - color: var(--eggshell); - transform: translateY(-2px); - box-shadow: 0 0.35rem 0.75rem rgb(10 34 64 / 0.25); -} - -.map-control:active { - transform: translateY(0); - box-shadow: none; -} - -.home-link { - position: absolute; - top: 0.5rem; - left: 3.75rem; - z-index: 3; - grid-auto-flow: column; - gap: 0.5rem; - height: 2.75rem; - padding-inline: 1rem; - border-radius: 999px; - text-decoration: none; -} - -.home-link svg { - width: 1.25rem; - height: 1.25rem; -} - /* ---------- Kartflaten ---------- */ .map-viewport { diff --git a/client/src/pages/MapPage.tsx b/client/src/pages/MapPage.tsx index b28d1db..404e791 100644 --- a/client/src/pages/MapPage.tsx +++ b/client/src/pages/MapPage.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { fetchPlaces } from '../api/places' import { HelpButton } from '../components/HelpButton' +import { HomeLink } from '../components/HomeLink' import { MapPanel } from '../components/map/MapPanel' import { MapViewport } from '../components/map/MapViewport' import type { Inset } from '../components/map/MapViewport' import { MenuButton } from '../components/MenuButton' -import { HOME_HREF } from '../hooks/useRoute' import { useNow } from '../hooks/useNow' import type { Place } from '../types/place' import './MapPage.css' @@ -111,22 +111,7 @@ export function MapPage() {
    - - - Hovedside - + li { + display: grid; + scroll-snap-align: start; +} + +@media (min-width: 40rem) { + .scroll-list { + grid-auto-columns: calc((100% - 2rem) / 3); + } +} + +@media (min-width: 72rem) { + .scroll-list { + grid-auto-columns: calc((100% - 3rem) / 4); + } +} + +.place-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem; + gap: 0.15rem 0.75rem; + align-content: center; + padding: 0.75rem; + border: 2px solid var(--blue-100); + border-radius: 0.9rem; + background: var(--surface); +} + +.place-card h3 { + margin: 0; + font-size: 1.05rem; + line-height: 1.25; +} + +.place-card-meta { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.place-card-figure { + grid-column: 2; + grid-row: 1 / span 3; + align-self: center; + margin: 0; + overflow: hidden; + border-radius: 0.5rem; + aspect-ratio: 4 / 3; +} + +/* ---------- Badges ---------- */ + +.badge-list { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0 0 0.75rem; + padding: 0; + list-style: none; +} + +.badge-slot { + display: block; + width: 4rem; + height: 4rem; + fill: var(--blue-100); + stroke: var(--blue-300); + stroke-dasharray: 6 5; + stroke-linejoin: round; + stroke-width: 3; +} diff --git a/client/src/pages/UserPage.tsx b/client/src/pages/UserPage.tsx new file mode 100644 index 0000000..4369b3e --- /dev/null +++ b/client/src/pages/UserPage.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from 'react' +import { Gulls } from '../components/Gulls' +import { HelpButton } from '../components/HelpButton' +import { HomeLink } from '../components/HomeLink' +import { MenuButton } from '../components/MenuButton' +import { Avatar } from '../components/user/Avatar' +import { BadgeSlots } from '../components/user/BadgeSlots' +import { CarouselSection } from '../components/user/CarouselSection' +import { FavouritePlaceCard } from '../components/user/FavouritePlaceCard' +import { ProfileSection } from '../components/user/ProfileSection' +import { useCurrentUser } from '../hooks/useCurrentUser' +import './UserPage.css' + +/** Små strek-ikoner som står foran overskriftene. */ +function SectionIcon({ children }: { children: ReactNode }) { + return ( + + ) +} + +const placesIcon = ( + + + + +) + +const routesIcon = ( + + + + + +) + +const badgesIcon = ( + + + + +) + +export function UserPage() { + const { user, status } = useCurrentUser() + + return ( + <> +
    + + + + Dette er profilen din, med navn og profilbilde øverst. Under + «Favorittsteder» samler du stedene du liker best, og har du mange, kan + du bla sidelengs med pilene eller ved å dra i raden. «Favorittruter» + og «Badges» fylles ut senere, når du kan lage egne ruter og fullføre + utfordringer. Innlogging kommer også senere, så foreløpig vises en + eksempelbruker. + + + + +

    {user?.name ?? 'Profil'}

    + {user && } +
    + +
    + {status === 'loading' &&

    Laster profil …

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

    + Kunne ikke hente profilen. Prøv å laste siden på nytt. +

    + )} + + {user && ( + <> + + {user.favouritePlaces.map((place) => ( + + ))} + + + + {[]} + + + + + + + )} +
    + + ) +} diff --git a/client/src/types/user.ts b/client/src/types/user.ts new file mode 100644 index 0000000..6c6ba42 --- /dev/null +++ b/client/src/types/user.ts @@ -0,0 +1,9 @@ +import type { Place } from './place' + +export type User = { + id: string + name: string + /** Null når brukeren mangler profilbilde; da vises en plassholder. */ + avatarUrl: string | null + favouritePlaces: Place[] +}