Add layout shell with header, footer, and theme toggle

This commit is contained in:
2026-04-23 21:11:22 +04:00
parent d5d666c56a
commit 128f9d80fb
7 changed files with 168 additions and 16 deletions

View File

@@ -0,0 +1,27 @@
import type { Globals, Menu } from '@/lib/types'
type Props = {
globals: Globals
menu: Menu | undefined
}
export function Footer({ globals, menu }: Props) {
return (
<footer className="mt-8 bg-muted/50">
<div className="flex h-16 items-center justify-between gap-4 px-4">
<nav className="flex gap-1">
{menu?.items.map((item) => (
<a
key={item.label}
href={item.url ?? '#'}
className="px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{item.label}
</a>
))}
</nav>
<p className="text-sm text-muted-foreground">{globals.copyright}</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1,34 @@
import { Link } from '@tanstack/react-router'
import type { Globals, Menu } from '@/lib/types'
import { ThemeToggle } from './theme-toggle'
type Props = {
globals: Globals
menu: Menu | undefined
}
export function Header({ globals, menu }: Props) {
return (
<header className="bg-muted/50">
<div className="flex h-16 items-center gap-4 px-4">
<Link to="/" className="text-base font-semibold hover:no-underline">
{globals.site_name}
</Link>
<nav className="flex gap-1 pl-2">
{menu?.items.map((item) => (
<a
key={item.label}
href={item.url ?? '#'}
className="px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{item.label}
</a>
))}
</nav>
<div className="ml-auto">
<ThemeToggle />
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,20 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { globalsQuery, menusQuery } from '@/lib/queries'
import { Header } from './header'
import { Footer } from './footer'
export function Shell({ children }: { children: React.ReactNode }) {
const { data: globals } = useSuspenseQuery(globalsQuery)
const { data: menus } = useSuspenseQuery(menusQuery)
const mainMenu = menus.find((m) => m.id === 'MAIN_MENU')
const footerMenu = menus.find((m) => m.id === 'FOOTER_MENU')
return (
<div className="mx-auto max-w-[90rem] px-4">
<Header globals={globals} menu={mainMenu} />
<main className="my-6">{children}</main>
<Footer globals={globals} menu={footerMenu} />
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/components/theme-provider'
export function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<Button variant="outline" size="icon" onClick={toggle} aria-label="Toggle theme">
{theme === 'light' ? <Moon /> : <Sun />}
</Button>
)
}

View File

@@ -0,0 +1,49 @@
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'light' | 'dark'
type ThemeContextValue = {
theme: Theme
toggle: () => void
set: (t: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
const STORAGE_KEY = 'theme'
function readInitial(): Theme {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(readInitial)
useEffect(() => {
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
return (
<ThemeContext.Provider
value={{
theme,
toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
set: setTheme,
}}
>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
return ctx
}