diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3e7744 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Backend API origin (no trailing slash) +VITE_API_BASE_URL=https://gig-platform.ddev.site diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..0401bb7 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,131 @@ +import { useAuthStore } from '@/auth/store' +import type { ApiErrorPayload, ApiResponse, ListParams, TokenGrant } from './types' + +const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '' + +export const ADMIN_CLIENT_MACHINE_NAME = 'admin-api-client' + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public errors?: Record, + ) { + super(message) + this.name = 'ApiError' + } +} + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + body?: unknown + params?: ListParams + /** skip Bearer + refresh handling (login/refresh calls) */ + anonymous?: boolean + signal?: AbortSignal +} + +function buildUrl(path: string, params?: ListParams): string { + const url = new URL(path, BASE_URL) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)) + } + } + } + return url.toString() +} + +// Single-flight refresh: concurrent 401s share one refresh attempt. +let refreshInFlight: Promise | null = null + +async function tryRefresh(): Promise { + refreshInFlight ??= (async () => { + const { refreshToken } = useAuthStore.getState() + if (!refreshToken) return false + try { + const res = await fetch(buildUrl('/api/v1/auth/refresh-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + refresh_token: refreshToken, + client_machine_name: ADMIN_CLIENT_MACHINE_NAME, + }), + }) + if (!res.ok) return false + const json = (await res.json()) as ApiResponse + if (!json.success || !json.data.access_token) return false + useAuthStore.getState().setTokens(json.data) + return true + } catch { + return false + } finally { + // allow the next expiry to trigger a fresh attempt + setTimeout(() => { + refreshInFlight = null + }, 0) + } + })() + + return refreshInFlight +} + +async function parseError(res: Response): Promise { + let payload: ApiErrorPayload | null = null + try { + payload = (await res.json()) as ApiErrorPayload + } catch { + // non-JSON error body + } + return new ApiError( + res.status, + payload?.code ?? `HTTP_${res.status}`, + payload?.message ?? res.statusText, + payload?.data?.errors, + ) +} + +async function rawRequest(path: string, options: RequestOptions, retried = false): Promise { + const { accessToken } = useAuthStore.getState() + const headers: Record = { + Accept: 'application/json', + 'X-Language-Locale': 'zh-CN', + } + if (options.body !== undefined) headers['Content-Type'] = 'application/json' + if (!options.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}` + + const res = await fetch(buildUrl(path, options.params), { + method: options.method ?? 'GET', + headers, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }) + + if (res.status === 401 && !options.anonymous && !retried) { + if (await tryRefresh()) { + return rawRequest(path, options, true) + } + useAuthStore.getState().clear() + } + + return res +} + +export async function request(path: string, options: RequestOptions = {}): Promise { + const res = await rawRequest(path, options) + if (!res.ok) throw await parseError(res) + return (await res.json()) as T +} + +export const api = { + get: (path: string, params?: ListParams, signal?: AbortSignal) => + request(path, { params, signal }), + post: (path: string, body?: unknown) => request(path, { method: 'POST', body }), + put: (path: string, body?: unknown) => request(path, { method: 'PUT', body }), + patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body }), + delete: (path: string, body?: unknown) => request(path, { method: 'DELETE', body }), + anonymousPost: (path: string, body?: unknown) => + request(path, { method: 'POST', body, anonymous: true }), +} diff --git a/src/api/modules/auth.ts b/src/api/modules/auth.ts new file mode 100644 index 0000000..d2bc348 --- /dev/null +++ b/src/api/modules/auth.ts @@ -0,0 +1,17 @@ +import { api } from '@/api/client' +import type { ApiResponse, MeResponse, TokenGrant } from '@/api/types' + +export function loginWithPassword(identity: string, password: string) { + return api.anonymousPost>('/api/admin/v1/auth/login/password', { + identity, + password, + }) +} + +export function fetchMe() { + return api.get('/api/admin/v1/auth/me') +} + +export function issueRobotSessionToken(uid: string) { + return api.post>(`/api/admin/v1/auth/robots/${uid}/token`) +} diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..cfebaa7 --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { AdminUser, TokenGrant } from '@/api/types' + +interface AuthState { + accessToken: string | null + refreshToken: string | null + user: AdminUser | null + roles: string[] + permissions: string[] + setTokens: (grant: TokenGrant) => void + setSession: (user: AdminUser, roles: string[], permissions: string[]) => void + clear: () => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + refreshToken: null, + user: null, + roles: [], + permissions: [], + setTokens: (grant) => + set({ + accessToken: grant.access_token, + refreshToken: grant.refresh_token ?? null, + }), + setSession: (user, roles, permissions) => set({ user, roles, permissions }), + clear: () => + set({ accessToken: null, refreshToken: null, user: null, roles: [], permissions: [] }), + }), + { name: 'gig-admin-auth' }, + ), +) + +export function hasRole(role: string): boolean { + return useAuthStore.getState().roles.includes(role) +} + +/** super-admin implicitly passes every permission check (mirrors backend seeding). */ +export function can(permission: string): boolean { + const { roles, permissions } = useAuthStore.getState() + return roles.includes('super-admin') || permissions.includes(permission) +} + +export function useCan(permission: string): boolean { + const roles = useAuthStore((s) => s.roles) + const permissions = useAuthStore((s) => s.permissions) + return roles.includes('super-admin') || permissions.includes(permission) +} + +export function isAuthenticated(): boolean { + return Boolean(useAuthStore.getState().accessToken) +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx deleted file mode 100644 index c8bfd17..0000000 --- a/src/components/Footer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -export default function Footer() { - const year = new Date().getFullYear() - - return ( - - ) -} diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index 4b558fb..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Link } from '@tanstack/react-router' -import ThemeToggle from './ThemeToggle' - -export default function Header() { - return ( -
- -
- ) -} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx deleted file mode 100644 index 081ebe2..0000000 --- a/src/components/ThemeToggle.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect, useState } from 'react' - -type ThemeMode = 'light' | 'dark' | 'auto' - -function getInitialMode(): ThemeMode { - if (typeof window === 'undefined') { - return 'auto' - } - - const stored = window.localStorage.getItem('theme') - if (stored === 'light' || stored === 'dark' || stored === 'auto') { - return stored - } - - return 'auto' -} - -function applyThemeMode(mode: ThemeMode) { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches - const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode - - document.documentElement.classList.remove('light', 'dark') - document.documentElement.classList.add(resolved) - - if (mode === 'auto') { - document.documentElement.removeAttribute('data-theme') - } else { - document.documentElement.setAttribute('data-theme', mode) - } - - document.documentElement.style.colorScheme = resolved -} - -export default function ThemeToggle() { - const [mode, setMode] = useState('auto') - - useEffect(() => { - const initialMode = getInitialMode() - setMode(initialMode) - applyThemeMode(initialMode) - }, []) - - useEffect(() => { - if (mode !== 'auto') { - return - } - - const media = window.matchMedia('(prefers-color-scheme: dark)') - const onChange = () => applyThemeMode('auto') - - media.addEventListener('change', onChange) - return () => { - media.removeEventListener('change', onChange) - } - }, [mode]) - - function toggleMode() { - const nextMode: ThemeMode = - mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light' - setMode(nextMode) - applyThemeMode(nextMode) - window.localStorage.setItem('theme', nextMode) - } - - const label = - mode === 'auto' - ? 'Theme mode: auto (system). Click to switch to light mode.' - : `Theme mode: ${mode}. Click to switch mode.` - - return ( - - ) -} diff --git a/src/components/confirm-dialog.tsx b/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..ade8ecc --- /dev/null +++ b/src/components/confirm-dialog.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' + +interface ConfirmDialogProps { + trigger: React.ReactNode + title: string + description?: string + confirmLabel?: string + destructive?: boolean + onConfirm: () => Promise | void +} + +export function ConfirmDialog({ + trigger, + title, + description, + confirmLabel = '确认', + destructive = false, + onConfirm, +}: ConfirmDialogProps) { + const [busy, setBusy] = useState(false) + + return ( + + {trigger} + + + {title} + {description && {description}} + + + 取消 + { + event.preventDefault() + setBusy(true) + try { + await onConfirm() + // close via Escape-equivalent: AlertDialogAction default closes, + // but we prevented it — dispatch close by clicking cancel sibling. + ;(event.target as HTMLElement) + .closest('[role="alertdialog"]') + ?.querySelector('[data-slot="alert-dialog-cancel"]') + ?.click() + } finally { + setBusy(false) + } + }} + > + {busy ? '处理中…' : confirmLabel} + + + + + ) +} diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx new file mode 100644 index 0000000..9f4fc94 --- /dev/null +++ b/src/components/data-table/data-table.tsx @@ -0,0 +1,155 @@ +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, +} from '@tanstack/react-table' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import type { Pagination } from '@/api/types' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +interface DataTableProps { + columns: ColumnDef[] + data: T[] + pagination?: Pagination + loading?: boolean + emptyText?: string + onPageChange?: (page: number) => void + onPageSizeChange?: (pageSize: number) => void +} + +/** + * Server-paginated table bound to the backend's Pagination envelope + * (page / page_size request params). + */ +export function DataTable({ + columns, + data, + pagination, + loading = false, + emptyText = '暂无数据', + onPageChange, + onPageSizeChange, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }) + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {columns.map((_, j) => ( + + + + ))} + + )) + ) : table.getRowModel().rows.length === 0 ? ( + + + {emptyText} + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ + {pagination && ( +
+
共 {pagination.total_count} 条
+
+ {onPageSizeChange && ( + + )} +
+ + + {pagination.current_page} / {Math.max(pagination.total_pages, 1)} + + +
+
+
+ )} +
+ ) +} diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts new file mode 100644 index 0000000..1af7550 --- /dev/null +++ b/src/components/layout/nav.ts @@ -0,0 +1,65 @@ +import type { LucideIcon } from 'lucide-react' +import { + Bot, + FolderTree, + Hash, + Inbox, + KeyRound, + Languages, + LayoutList, + ListChecks, + Newspaper, + Palette, + ScrollText, + Smartphone, + Sparkles, +} from 'lucide-react' + +export interface NavItem { + label: string + to: string + icon: LucideIcon + /** hide when the user lacks this permission (super-admin always passes) */ + permission?: string +} + +export interface NavGroup { + label: string + items: NavItem[] +} + +export const NAV_GROUPS: NavGroup[] = [ + { + label: '分类管理', + items: [ + { label: '分类树', to: '/categories', icon: FolderTree }, + { label: '待审核分类', to: '/categories/pending', icon: ListChecks }, + ], + }, + { + label: '内容工作室', + items: [ + { label: '机器人作者', to: '/studio/robot-authors', icon: Bot }, + { label: '文章系列', to: '/studio/series', icon: LayoutList }, + { label: '文章', to: '/studio/articles', icon: Newspaper }, + { label: '子话题', to: '/studio/subtopics', icon: Sparkles }, + { label: '分类简报', to: '/studio/category-briefs', icon: ScrollText }, + { label: '风格预设', to: '/studio/style-presets', icon: Palette }, + { + label: 'Agent 接入', + to: '/studio/agent-tokens', + icon: KeyRound, + permission: 'admin:studio:agent-token:issue', + }, + ], + }, + { + label: '用户与系统', + items: [ + { label: 'SID 管理', to: '/sid', icon: Hash }, + { label: '语言设置', to: '/locales', icon: Languages }, + { label: '推送设备', to: '/devices', icon: Smartphone }, + { label: '机器人会话', to: '/robots', icon: Inbox }, + ], + }, +] diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx new file mode 100644 index 0000000..80c483e --- /dev/null +++ b/src/components/page-header.tsx @@ -0,0 +1,17 @@ +interface PageHeaderProps { + title: string + description?: string + actions?: React.ReactNode +} + +export function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ) +} diff --git a/src/components/show-once-token-dialog.tsx b/src/components/show-once-token-dialog.tsx new file mode 100644 index 0000000..5406969 --- /dev/null +++ b/src/components/show-once-token-dialog.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { Check, Copy } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +interface ShowOnceTokenDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + token: string | null + /** extra command/snippet shown below the token (e.g. claude mcp add …) */ + snippet?: string +} + +/** + * Displays a freshly issued token exactly once. The value is not retrievable + * again after closing — the copy affordance is the whole point. + */ +export function ShowOnceTokenDialog({ + open, + onOpenChange, + title, + description = '此令牌仅完整显示一次,请立即复制保存。', + token, + snippet, +}: ShowOnceTokenDialogProps) { + const [copied, setCopied] = useState(false) + + const copy = async (value: string) => { + await navigator.clipboard.writeText(value) + setCopied(true) + toast.success('已复制到剪贴板') + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + {title} + {description} + +
+
+ {token} + +
+ {snippet && ( +
+              {snippet}
+            
+ )} +
+ + + +
+
+ ) +} diff --git a/src/components/status-pill.tsx b/src/components/status-pill.tsx new file mode 100644 index 0000000..38e30a7 --- /dev/null +++ b/src/components/status-pill.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/lib/utils' + +const TONES: Record = { + // generic + active: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400', + approved: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400', + published: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400', + ready: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400', + complete: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400', + pending: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + drafting: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + drafted: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + in_progress: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + profiling: 'bg-blue-500/12 text-blue-700 dark:text-blue-400', + scheduled: 'bg-violet-500/12 text-violet-700 dark:text-violet-400', + planned: 'bg-muted text-muted-foreground', + paused: 'bg-muted text-muted-foreground', + archived: 'bg-muted text-muted-foreground', + rejected: 'bg-red-500/12 text-red-700 dark:text-red-400', +} + +const LABELS: Record = { + active: '启用', + approved: '已通过', + published: '已发布', + ready: '就绪', + complete: '已完成', + pending: '待处理', + drafting: '撰写中', + drafted: '已完稿', + in_progress: '进行中', + profiling: '设定中', + scheduled: '已排期', + planned: '已规划', + paused: '已暂停', + archived: '已归档', + rejected: '已驳回', +} + +export function StatusPill({ status, className }: { status: string; className?: string }) { + return ( + + {LABELS[status] ?? status} + + ) +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..ba3724a --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,24 @@ +import { toast } from 'sonner' +import type { FieldValues, Path, UseFormSetError } from 'react-hook-form' +import { ApiError } from '@/api/client' + +/** + * Standard error handling: 422 field errors land on the form (when given), + * everything else surfaces as a toast with the backend's localized message. + */ +export function handleApiError( + error: unknown, + setError?: UseFormSetError, +): void { + if (error instanceof ApiError) { + if (error.status === 422 && error.errors && setError) { + for (const [field, messages] of Object.entries(error.errors)) { + setError(field as Path, { type: 'server', message: messages[0] }) + } + return + } + toast.error(error.message || `请求失败(${error.code})`) + return + } + toast.error('网络异常,请稍后重试') +} diff --git a/src/lib/list-page.ts b/src/lib/list-page.ts new file mode 100644 index 0000000..20f507a --- /dev/null +++ b/src/lib/list-page.ts @@ -0,0 +1,41 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import type { ListParams, PaginatedResponse } from '@/api/types' + +/** + * Standard list-page state: page / page_size wired to the backend's + * Pagination envelope; changing filters snaps back to page 1. + */ +export function useListPage( + key: string, + fetcher: (params: ListParams) => Promise>, + filters: ListParams = {}, +) { + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const filterKey = JSON.stringify(filters) + + useEffect(() => { + setPage(1) + }, [filterKey]) + + const query = useQuery({ + queryKey: [key, filterKey, page, pageSize], + queryFn: () => fetcher({ ...filters, page, page_size: pageSize }), + placeholderData: keepPreviousData, + }) + + return { + query, + rows: query.data?.data ?? [], + pagination: query.data?.pagination, + loading: query.isPending, + page, + setPage, + setPageSize: (size: number) => { + setPageSize(size) + setPage(1) + }, + } +} diff --git a/src/router.tsx b/src/router.tsx index e7b1c4d..d4603b9 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,14 +1,33 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { QueryClient } from '@tanstack/react-query' +import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' import { routeTree } from './routeTree.gen' export function getRouter() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: (failureCount, error) => { + // Never retry auth/permission/validation errors. + const status = (error as { status?: number }).status + if (status === 401 || status === 403 || status === 404 || status === 422) return false + return failureCount < 2 + }, + }, + }, + }) + const router = createTanStackRouter({ routeTree, + context: { queryClient }, scrollRestoration: true, defaultPreload: 'intent', defaultPreloadStaleTime: 0, }) + setupRouterSsrQueryIntegration({ router, queryClient }) + return router } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 3f7f8c2..235f466 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,59 +1,43 @@ -import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' -import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' -import { TanStackDevtools } from '@tanstack/react-devtools' -import Footer from '../components/Footer' -import Header from '../components/Header' +import { + HeadContent, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' +import { Toaster } from '@/components/ui/sonner' import appCss from '../styles.css?url' -const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();` +const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=stored==='dark'||(stored!=='light'&&prefersDark)?'dark':'light';document.documentElement.classList.toggle('dark',resolved==='dark');document.documentElement.style.colorScheme=resolved;}catch(e){}})();` -export const Route = createRootRoute({ +interface RouterContext { + queryClient: QueryClient +} + +export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ - { - charSet: 'utf-8', - }, - { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }, - { - title: 'TanStack Start Starter', - }, - ], - links: [ - { - rel: 'stylesheet', - href: appCss, - }, + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Gig 管理后台' }, ], + links: [{ rel: 'stylesheet', href: appCss }], }), shellComponent: RootDocument, + component: () => , }) function RootDocument({ children }: { children: React.ReactNode }) { return ( - +