Add layout shell with header, footer, and theme toggle
This commit is contained in:
27
frontend/src/components/layout/footer.tsx
Normal file
27
frontend/src/components/layout/footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/layout/header.tsx
Normal file
34
frontend/src/components/layout/header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/layout/shell.tsx
Normal file
20
frontend/src/components/layout/shell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
frontend/src/components/layout/theme-toggle.tsx
Normal file
12
frontend/src/components/layout/theme-toggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
frontend/src/components/theme-provider.tsx
Normal file
49
frontend/src/components/theme-provider.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user