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
}

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router' import { RouterProvider, createRouter } from '@tanstack/react-router'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import { routeTree } from '@generated/tanstack-router/routeTree.gen' import { routeTree } from '@generated/tanstack-router/routeTree.gen'
import { ThemeProvider } from './components/theme-provider'
import './index.css' import './index.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -31,9 +32,11 @@ declare module '@tanstack/react-router' {
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<HelmetProvider> <HelmetProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
</HelmetProvider> </HelmetProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,17 +1,18 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { Helmet } from 'react-helmet-async'
import { useSuspenseQuery } from '@tanstack/react-query'
import { globalsQuery, menusQuery } from '@/lib/queries' import { globalsQuery, menusQuery } from '@/lib/queries'
import { Shell } from '@/components/layout/shell'
const TanStackRouterDevtools = const TanStackRouterDevtools = import.meta.env.PROD
import.meta.env.PROD
? () => null ? () => null
: lazy(() => : lazy(() =>
import('@tanstack/react-router-devtools').then((m) => ({ default: m.TanStackRouterDevtools })), import('@tanstack/react-router-devtools').then((m) => ({ default: m.TanStackRouterDevtools })),
) )
const ReactQueryDevtools = const ReactQueryDevtools = import.meta.env.PROD
import.meta.env.PROD
? () => null ? () => null
: lazy(() => : lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })), import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
@@ -25,9 +26,15 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
}) })
function RootComponent() { function RootComponent() {
const { data: globals } = useSuspenseQuery(globalsQuery)
return ( return (
<> <>
<Helmet defaultTitle={globals.meta_title} titleTemplate={`%s — ${globals.meta_title}`}>
<meta name="description" content={globals.meta_description} />
</Helmet>
<Shell>
<Outlet /> <Outlet />
</Shell>
<Suspense> <Suspense>
<TanStackRouterDevtools /> <TanStackRouterDevtools />
<ReactQueryDevtools buttonPosition="bottom-left" /> <ReactQueryDevtools buttonPosition="bottom-left" />