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
138 changes: 138 additions & 0 deletions client/src/components/layout/BottomNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { NavLink } from 'react-router';
import BookIcon from '@mui/icons-material/Book';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import FavoriteIcon from '@mui/icons-material/Favorite';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import PersonOutlinedIcon from '@mui/icons-material/PersonOutlined';
import PersonIcon from '@mui/icons-material/Person';
import AddIcon from '@mui/icons-material/Add';
import type { SvgIconComponent } from '@mui/icons-material';
import { useCurrentUser } from '../../lib/useCurrentUser';
import { LoginRequiredDialog } from './LoginRequiredDialog';
import { NewRecipeButton } from './NewRecipeButton';

// rosa fokusring
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-light';

// ikon over tekst, rødt og bold når aktiv
function tabClass({ isActive }: { isActive: boolean }) {
return `flex flex-col items-center gap-1 text-[11px] transition-colors ${FOCUS_RING} ${
isActive ? 'font-bold text-primary' : 'text-text'
}`;
}

type TabLinkProps = {
to: string;
label: string;
icon: SvgIconComponent;
activeIcon: SvgIconComponent;
};

// ett valg i bunnmenyen og bytter ikon på aktiv side
function TabLink({
to,
label,
icon: Icon,
activeIcon: ActiveIcon,
}: TabLinkProps) {
return (
<NavLink to={to} className={tabClass}>
{({ isActive }) => (
<>
{isActive ? (
<ActiveIcon aria-hidden="true" />
) : (
<Icon aria-hidden="true" />
)}
{label}
</>
)}
</NavLink>
);
}

// menyen nederst på mobil
export function BottomNav() {
const { user } = useCurrentUser();

// ser ut som et valg men åpner innloggingspopupen
const favoritesButton = (
<button
type="button"
className={`cursor-pointer ${tabClass({ isActive: false })}`}
>
<FavoriteBorderIcon aria-hidden="true" />
Favoritter
</button>
);

// den runde plussknappen i midten
const newRecipeButton = (
<button
type="button"
aria-label="Ny oppskrift"
className={`cursor-pointer ${tabClass({ isActive: false })}`}
>
<span className="-mt-6 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-background shadow-md">
<AddIcon aria-hidden="true" />
</span>
Ny
</button>
);

return (
<nav
aria-label="Hovedmeny"
className="fixed inset-x-0 bottom-0 z-40 border-t border-secondary bg-background desktop:hidden"
>
<ul className="grid grid-cols-5 items-end px-2 pt-2 pb-3">
<li className="flex justify-center">
<TabLink
to="/recipes"
label="Oppskrifter"
icon={BookIcon}
activeIcon={MenuBookIcon}
/>
</li>
<li className="flex justify-center">
{user ? (
<TabLink
to="/favorites"
label="Favoritter"
icon={FavoriteBorderIcon}
activeIcon={FavoriteIcon}
/>
) : (
<LoginRequiredDialog
trigger={favoritesButton}
message="Logg inn eller fortsett som gjest for å lagre favoritter."
/>
)}
</li>
<li className="flex justify-center">
<NewRecipeButton trigger={newRecipeButton} />
</li>
<li className="flex justify-center">
<TabLink
to="/shopping-list"
label="Handleliste"
icon={ShoppingCartIcon}
activeIcon={AddShoppingCartIcon}
/>
</li>
<li className="flex justify-center">
{/* fylt ikon når innlogget */}
<TabLink
to={user ? '/profile' : '/login'}
label={user ? 'Min side' : 'Logg inn'}
icon={user ? PersonIcon : PersonOutlinedIcon}
activeIcon={PersonIcon}
/>
</li>
</ul>
</nav>
);
}
153 changes: 26 additions & 127 deletions client/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,139 +1,38 @@
import { useState } from 'react';
import { Link, NavLink, useSearchParams } from 'react-router';
import SearchBar from './SearchBar';
import { Button } from '../ui/Button';
import { Dialog } from '../ui/Dialog';
import NewRecipePage from '../../routes/NewRecipePage';
import { RecipeStepIndicator } from '../new-recipe/RecipeStepIndicator';
import { useCurrentUser } from '../../lib/useCurrentUser';
import { LoginRequiredDialog } from './LoginRequiredDialog';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { TopHeader } from './TopHeader';
import { StickyHeader } from './StickyHeader';
import { MobileHeader } from './MobileHeader';

// Fokusmarkering ved tastaturnavigasjon
const focusRing =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary';

// Aktiv lenke markeres med både farge og understrek
function navLinkClass({ isActive }: { isActive: boolean }) {
const base = `whitespace-nowrap font-body transition-colors hover:text-primary ${focusRing}`;

return isActive
? `${base} text-primary underline underline-offset-4`
: `${base} text-text`;
}

// Styling for knapper/lenker i header
const buttonBase = `whitespace-nowrap rounded-medium px-3 py-2 text-sm font-body transition-colors ${focusRing}`;

// Brukerknapp
function userClass({ isActive }: { isActive: boolean }) {
const base = `${buttonBase} border border-primary`;

return isActive
? `${base} bg-primary text-background`
: `${base} text-primary hover:bg-pink-light`;
}
// hvor langt man scroller før den lille headeren vises
const SCROLL_LIMIT = 160;

export default function Header() {
// hvem som er logget inn
const { user } = useCurrentUser();
// Søkeordet fra URL-en
// Søkeordet fra URL
const [searchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';

// holder styr på hvilket steg i Ny oppskrift brukeren er på
const [currentStep, setCurrentStep] = useState(1);

// knapp som åpner ny oppskrift eller innloggingspopup hvis man ikke er logget inn
const newRecipeButton = (
<Button variant="primary" size="small">
Ny Oppskrift
</Button>
);

// åpner innloggings popupen
const favoritesButton = (
<button
type="button"
className={`cursor-pointer ${navLinkClass({ isActive: false })}`}
>
Favoritter
</button>
);

return (
<header className="border-b border-secondary bg-background px-6 py-4">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-8 gap-y-4">
{/* Logo */}
<Link
to="/"
className={`shrink-0 font-heading text-3xl text-primary ${focusRing}`}
>
Oppskriftsbok
</Link>

{/* Søkefelt */}
<SearchBar key={query} initialQuery={query} />
// om man har scrollet forbi den store headeren
const [scrolled, setScrolled] = useState(false);

{/* Navigasjon */}
<nav aria-label="Hovedmeny">
<ul className="flex flex-wrap items-center gap-x-6 gap-y-3">
<li>
<NavLink to="/recipes" className={navLinkClass}>
Oppskrifter
</NavLink>
</li>
useEffect(() => {
function handleScroll() {
setScrolled(window.scrollY > SCROLL_LIMIT);
}

{/* favoritter går gjennom når innlogget popup hvis ikke */}
<li>
{user ? (
<NavLink to="/favorites" className={navLinkClass}>
Favoritter
</NavLink>
) : (
<LoginRequiredDialog
trigger={favoritesButton}
message="Logg inn eller fortsett som gjest for å lagre favoritter."
/>
)}
</li>
// sjekker med en gang hvis siden åpnes midt på
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });

<li>
<NavLink to="/shopping-list" className={navLinkClass}>
Handleliste
</NavLink>
</li>
// slutter når man forlater siden
return () => window.removeEventListener('scroll', handleScroll);
}, []);

{/* ny oppskrift */}
<li>
{user ? (
<Dialog
trigger={newRecipeButton}
title="Ny oppskrift"
headerContent={
<RecipeStepIndicator currentStep={currentStep} />
}
>
<NewRecipePage
currentStep={currentStep}
setCurrentStep={setCurrentStep}
/>
</Dialog>
) : (
<LoginRequiredDialog
trigger={newRecipeButton}
message="Logg inn eller fortsett som gjest for å lage egne oppskrifter."
/>
)}
</li>

<li>
<NavLink to={user ? '/profile' : '/login'} className={userClass}>
{user ? 'Bruker' : 'Logg inn'}
</NavLink>
</li>
</ul>
</nav>
</div>
</header>
return (
<>
<MobileHeader query={query} />
<TopHeader query={query} scrolled={scrolled} />
<StickyHeader visible={scrolled} query={query} />
</>
);
}
8 changes: 6 additions & 2 deletions client/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Outlet } from 'react-router';
import Header from './Header';
import { BottomNav } from './BottomNav';

// Felles ramme for alle sider
// felles ramme for alle sider
export default function Layout() {
return (
<div className="min-h-screen bg-background text-text">
<Header />

<main className="mx-auto w-full max-w-6xl px-4 py-8">
{/* ekstra plass nederst på mobil så bunnmenyen ikke dekker innholdet */}
<main className="mx-auto w-full max-w-6xl px-4 pt-8 pb-28 desktop:pb-8">
<Outlet />
</main>

<BottomNav />
</div>
);
}
4 changes: 2 additions & 2 deletions client/src/components/layout/LoginRequiredDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export function LoginRequiredDialog({
{/* knappene som åpner popupen */}
<RadixDialog.Trigger asChild>{trigger}</RadixDialog.Trigger>
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 bg-black/40" />
<RadixDialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
{/* popup */}
<RadixDialog.Content className="fixed top-1/2 left-1/2 w-[94%] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-large bg-background p-6 text-text shadow-lg focus:outline-none">
<RadixDialog.Content className="fixed top-1/2 left-1/2 z-50 w-[94%] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-large bg-background p-6 text-text shadow-lg focus:outline-none">
<RadixDialog.Title className="font-heading text-3xl">
Du må logge inn for å bruke dette
</RadixDialog.Title>
Expand Down
57 changes: 57 additions & 0 deletions client/src/components/layout/MobileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useState } from 'react';
import { Link } from 'react-router';
import SearchIcon from '@mui/icons-material/Search';
import SearchBar from './SearchBar';

type MobileHeaderProps = {
query: string;
};

// headeren på mobil med logo og søk
export function MobileHeader({ query }: MobileHeaderProps) {
// om søkefeltet er åpent
const [searchOpen, setSearchOpen] = useState(false);

return (
<header className="sticky top-0 z-40 border-b border-secondary bg-background desktop:hidden">
<div className="flex items-center justify-between gap-4 px-4 py-2">
{/* søkefeltet tar hele linja når det er åpent */}
{searchOpen ? (
<div
className="flex flex-1 justify-center"
onKeyDown={(event) =>
event.key === 'Escape' && setSearchOpen(false)
}
onBlur={(event) => {
// lukker når man trykker utenfor
if (!event.currentTarget.contains(event.relatedTarget)) {
setSearchOpen(false);
}
}}
>
<SearchBar
key={query}
initialQuery={query}
inputId="mobile-search"
autoFocus
/>
</div>
) : (
<>
<Link to="/" className="font-heading text-4xl text-primary">
Oppskriftsbok
</Link>
<button
type="button"
onClick={() => setSearchOpen(true)}
aria-label="Søk"
className="flex cursor-pointer rounded-full p-1 text-3xl text-primary transition-colors hover:bg-pink-light focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-light"
>
<SearchIcon fontSize="inherit" aria-hidden="true" />
</button>
</>
)}
</div>
</header>
);
}
Loading