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 { HelmetProvider } from 'react-helmet-async'
|
||||
import { routeTree } from '@generated/tanstack-router/routeTree.gen'
|
||||
import { ThemeProvider } from './components/theme-provider'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -31,9 +32,11 @@ declare module '@tanstack/react-router' {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { globalsQuery, menusQuery } from '@/lib/queries'
|
||||
import { Shell } from '@/components/layout/shell'
|
||||
|
||||
const TanStackRouterDevtools =
|
||||
import.meta.env.PROD
|
||||
? () => null
|
||||
: lazy(() =>
|
||||
import('@tanstack/react-router-devtools').then((m) => ({ default: m.TanStackRouterDevtools })),
|
||||
)
|
||||
const TanStackRouterDevtools = import.meta.env.PROD
|
||||
? () => null
|
||||
: lazy(() =>
|
||||
import('@tanstack/react-router-devtools').then((m) => ({ default: m.TanStackRouterDevtools })),
|
||||
)
|
||||
|
||||
const ReactQueryDevtools =
|
||||
import.meta.env.PROD
|
||||
? () => null
|
||||
: lazy(() =>
|
||||
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
|
||||
)
|
||||
const ReactQueryDevtools = import.meta.env.PROD
|
||||
? () => null
|
||||
: lazy(() =>
|
||||
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
|
||||
)
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
@@ -25,9 +26,15 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
const { data: globals } = useSuspenseQuery(globalsQuery)
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<Helmet defaultTitle={globals.meta_title} titleTemplate={`%s — ${globals.meta_title}`}>
|
||||
<meta name="description" content={globals.meta_description} />
|
||||
</Helmet>
|
||||
<Shell>
|
||||
<Outlet />
|
||||
</Shell>
|
||||
<Suspense>
|
||||
<TanStackRouterDevtools />
|
||||
<ReactQueryDevtools buttonPosition="bottom-left" />
|
||||
|
||||
Reference in New Issue
Block a user