The screen slice 1's API was written for: install from a release URL, enable, disable, uninstall, purge, and restart. Admin-only, matching the server, and core's own screen because it is how a module reaches the volume at all. 182 client tests (+21), manifest and OpenAPI unchanged. Everything that decides what a row SAYS and which buttons it offers is in `lib/moduleAdmin.js` -- plain JS, so the DOM-less runner can reach it, the same reason `lib/adminNav.js` is. The JSX renders what it returns. Three sources of truth, and they are allowed to disagree -------------------------------------------------------- The row records what the operator decided and what the last boot did; the loader says what is mounted and answering; the volume says whether there is a directory at all. Picking one and rendering it is simpler and lies. The case that makes it concrete is the one decision 3 creates on purpose: disable a module (its onShutdown runs) and enable it again, and the row says `enabled` while the loader still says `disabled` because nothing can start it before a restart. Neither "Running" nor "Disabled" is true; "Restart to start" is. Two shapes that are deliberately unlike the rest of the panel: the restart is a BANNER, because a restart is a property of the server rather than of a module and an operator who installed three modules should restart once; and purge is offered inside the uninstall flow as a second confirm, because purge.sql lives inside the directory being deleted and there is no later. What the browser found that no test could ----------------------------------------- Installing over a row the previous boot had left `startup_failed` rendered "Failed at the require stage: module directory not present on the volume" one second after the files had been written to the volume -- and, because that branch is not pending, it suppressed the restart banner the install had just told the operator to use. Every unit test passed, because none of them had modelled a stale row plus a fresh install. The fix is a derivation rather than a special case: the loader scans the volume once at require time, so a module that is on the volume now and has no live record arrived after that scan, and everything the row says about it predates the install. That check runs before the failure one. The same class, one place further on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. `liveVersion` (slice 1) lets the screen say "Restart to finish upgrading" instead of reporting the new version as running. Verified against a live server and the real published release: pasted the v0.3.0 install-manifest URL, restarted, watched the module register its five mounts and seven streams and its own nav rows appear in the sidebar. Disable ran its onShutdown for real -- the uo-link WebSocket closed, its routes went to 404, and it left /public/modules -- and enable then showed the decision-3 state with the banner. The restart button itself was exercised through its endpoint rather than clicked, because a window.confirm wedges the browser automation. Co-Authored-By: Claude <noreply@anthropic.com>
425 lines
19 KiB
JavaScript
425 lines
19 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
|
import MoonDot from '../../components/MoonDot.jsx'
|
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
|
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
|
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
|
import { withModuleNav } from '../../modules/nav.js'
|
|
import { useFeatureGate } from '../../modules/features.jsx'
|
|
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
|
|
|
|
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
|
// One shared frame keeps them terse; each item just supplies its path(s).
|
|
function Icon({ children, size = 16 }) {
|
|
return (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
>
|
|
{children}
|
|
</svg>
|
|
)
|
|
}
|
|
const IconHome = () => <Icon><path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></Icon>
|
|
const IconPosts = () => <Icon><path d="M5 3h14v18H5z" /><path d="M8 8h8M8 12h8M8 16h5" /></Icon>
|
|
const IconWiki = () => <Icon><path d="M4 4h9a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z" /><path d="M20 4h-2a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h2z" /></Icon>
|
|
const IconPages = () => <Icon><path d="M5 3h9l5 5v13H5z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h8" /></Icon>
|
|
const IconActivity = () => <Icon><path d="M3 12h4l3 8 4-16 3 8h4" /></Icon>
|
|
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
|
const IconUsers = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20a6 6 0 0 1 12 0" /><path d="M16 6a3 3 0 0 1 0 6M17 20a6 6 0 0 0-3-5" /></Icon>
|
|
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
|
const IconHero = () => <Icon><path d="M3 5h18v14H3z" /><circle cx="8" cy="10" r="1.6" /><path d="M4 18l5-5 3 3 3-4 5 6" /></Icon>
|
|
const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18 12v3M15 12v2" /></Icon>
|
|
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
|
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
|
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
|
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
|
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
|
|
|
|
// Nav is grouped into collapsible categories. A group with no `title` renders
|
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
|
// (when present) matches server-side enforcement so the sidebar never shows a
|
|
// link that would 403; an item without `roles` is visible to everyone.
|
|
// Moderators are further confined to just their section + account (see below).
|
|
//
|
|
// Exported because Admin -> Navigation edits this list. It stays declared here:
|
|
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
|
|
// touch (§7) — navItemVisibleTo below is the filter that still decides.
|
|
export const NAV = [
|
|
{
|
|
items: [
|
|
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
|
],
|
|
},
|
|
{
|
|
title: 'Content',
|
|
items: [
|
|
{ to: '/admin/posts', label: 'Posts', icon: IconPosts, roles: ['admin', 'editor'] },
|
|
{ to: '/admin/pages', label: 'Pages', icon: IconPages, roles: ['admin', 'editor'] },
|
|
{ to: '/admin/wiki', label: 'Wiki', icon: IconWiki, roles: ['admin', 'editor'] },
|
|
{ to: '/admin/activity', label: 'Activity', icon: IconActivity, roles: ['admin', 'editor'] },
|
|
],
|
|
},
|
|
{
|
|
title: 'Moderation',
|
|
items: [
|
|
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
|
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
|
],
|
|
},
|
|
{
|
|
title: 'System',
|
|
items: [
|
|
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
|
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
|
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
|
// Admin-only, matching the server: every route under /admin/modules
|
|
// re-gates to `admin` on top of the group's staff gate, because installing
|
|
// a module runs its code in this process.
|
|
{ to: '/admin/modules', label: 'Modules', icon: IconModules, roles: ['admin'] },
|
|
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
|
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
|
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
|
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
|
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
|
],
|
|
},
|
|
{
|
|
items: [
|
|
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
|
],
|
|
},
|
|
]
|
|
|
|
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
|
|
|
// The one row an override may never hide: the nav editor itself, which is the
|
|
// only screen that can un-hide anything. The write path already refuses it
|
|
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
|
// this is the third guard, and the one that also covers a row edited straight
|
|
// in the database. Cheap, and it makes "cannot be hidden" true without
|
|
// qualification.
|
|
const UNHIDEABLE = '/admin/navigation'
|
|
|
|
function keepEditorReachable(overrides) {
|
|
const entry = overrides?.[UNHIDEABLE]
|
|
if (!entry || entry.hidden !== true) return overrides
|
|
const { hidden, ...rest } = entry
|
|
return { ...overrides, [UNHIDEABLE]: rest }
|
|
}
|
|
|
|
// Who may see a sidebar row, and where that lets them go, both derived from the
|
|
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
|
|
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
|
|
// because Admin -> Navigation has always imported it from here.
|
|
export { navItemVisibleTo }
|
|
|
|
const TITLES = {
|
|
'/admin': 'Dashboard',
|
|
'/admin/posts': 'Posts',
|
|
'/admin/pages': 'Pages',
|
|
'/admin/wiki': 'Wiki Pages',
|
|
'/admin/hero': 'Hero Editor',
|
|
'/admin/moderation': 'Moderation',
|
|
'/admin/moderation/appeals': 'Appeals',
|
|
'/admin/settings': 'Site Settings',
|
|
'/admin/appearance': 'Appearance',
|
|
'/admin/navigation': 'Navigation',
|
|
'/admin/activity': 'Activity Log',
|
|
'/admin/bot-activity': 'Web Bot Activity',
|
|
'/admin/discord-bot': 'Discord Bot',
|
|
'/admin/auth-providers': 'Authentication',
|
|
'/admin/users': 'Users',
|
|
'/admin/invites': 'Invites',
|
|
'/admin/account': 'Account Security',
|
|
}
|
|
|
|
// An installed module's admin pages are not in TITLES and cannot be — core does
|
|
// not know what they are called. Their nav row does, so the row is the title:
|
|
// the longest matching module row wins, so a detail page under a section titles
|
|
// as that section rather than falling through to a bare "Admin". Restricted to
|
|
// rows a module registered, which is what keeps every core path resolving
|
|
// through TITLES and sectionTitle exactly as it does today.
|
|
function moduleTitle(baseNav, pathname) {
|
|
return baseNav
|
|
.flatMap((g) => g.items)
|
|
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
|
|
.sort((a, b) => b.to.length - a.to.length)[0]?.label
|
|
}
|
|
|
|
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
|
|
function sectionTitle(pathname) {
|
|
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
|
if (pathname.startsWith('/admin/users/')) return 'User'
|
|
return 'Admin'
|
|
}
|
|
|
|
const navBtnBase = {
|
|
textAlign: 'left',
|
|
borderRadius: 8,
|
|
padding: '10px 14px',
|
|
fontFamily: 'var(--sans)',
|
|
fontSize: '0.92rem',
|
|
textDecoration: 'none',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
transition: 'background .15s,color .15s',
|
|
}
|
|
|
|
export default function AdminLayout() {
|
|
const { user, logout } = useAuth()
|
|
const { mode, siteTitle } = useSite()
|
|
const navOverrides = useNavOverrides()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const isVisible = useFeatureGate()
|
|
|
|
// Core's rows plus every installed module's, before the override merge sees
|
|
// them — so a module row is editable in Admin -> Navigation like any other
|
|
// (modules/nav.js). Computed once: the registry is fixed before the first
|
|
// render and nothing unregisters.
|
|
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
|
|
const title =
|
|
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
|
|
// The hero canvas editor needs room — let it use the full content width.
|
|
const wide = location.pathname === '/admin/hero'
|
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
|
|
|
const isModerator = user?.role === 'moderator'
|
|
|
|
// An admin may relabel, reorder, hide and regroup these rows from Admin →
|
|
// Navigation. The merge runs FIRST and the role filter after it, so the filter
|
|
// stays the boundary: an override cannot show a moderator a row their role
|
|
// gate hides, whatever it says. With no stored row applyNavOverrides returns
|
|
// NAV itself and this is exactly the code that ran before the feature.
|
|
const navGroups = useMemo(
|
|
() =>
|
|
applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
|
|
.map((g) => ({
|
|
...g,
|
|
// `isVisible` is a no-op for every core row — none carries a `feature`
|
|
// — and is applied here so that a module row which does carry one is
|
|
// gated on the sidebar rather than silently advertised.
|
|
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
|
|
}))
|
|
// Drop any now-empty group so an empty category header never renders.
|
|
.filter((g) => g.items.length > 0),
|
|
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
|
|
)
|
|
|
|
// Accordion: track which titled categories are collapsed. Persist across
|
|
// reloads; default all-open. The group holding the active route auto-opens.
|
|
const [collapsed, setCollapsed] = useState(() => {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
})
|
|
const toggleGroup = (title) => {
|
|
setCollapsed((prev) => {
|
|
const next = { ...prev, [title]: !prev[title] }
|
|
try {
|
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next))
|
|
} catch {
|
|
/* private mode / quota — collapse is non-essential */
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
const activeGroupTitle = navGroups.find((g) =>
|
|
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
|
)?.title
|
|
|
|
// Where a moderator may go, from the same `roles` that decide what they see.
|
|
// It used to be a third hardcoded list — a prefix check over three paths —
|
|
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
|
|
// was on the sidebar and not in the redirect, so a moderator who clicked
|
|
// Houses in their own nav was bounced straight back to Moderation. One
|
|
// derivation cannot disagree with itself, which is the point of deriving it.
|
|
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
|
|
|
|
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
|
// outside their remit — the API would 403 anyway, so send them to their home.
|
|
useEffect(() => {
|
|
if (!isModerator) return
|
|
if (!isAllowedPath(location.pathname, allowed)) {
|
|
navigate('/admin/moderation', { replace: true })
|
|
}
|
|
}, [isModerator, location.pathname, navigate, allowed])
|
|
|
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
|
useEffect(() => {
|
|
const meta = document.createElement('meta')
|
|
meta.name = 'robots'
|
|
meta.content = 'noindex, nofollow'
|
|
document.head.appendChild(meta)
|
|
return () => document.head.removeChild(meta)
|
|
}, [])
|
|
|
|
async function signOut() {
|
|
await logout()
|
|
navigate('/admin/login', { replace: true })
|
|
}
|
|
|
|
return (
|
|
<div className="admin-grid">
|
|
{/* Sidebar */}
|
|
<aside
|
|
style={{
|
|
borderRight: '1px solid var(--line)',
|
|
background: 'var(--bg)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'sticky',
|
|
top: 0,
|
|
height: '100vh',
|
|
}}
|
|
>
|
|
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<BrandLogo height={24} />
|
|
<MoonDot />
|
|
<div>
|
|
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
|
{siteTitle}
|
|
</div>
|
|
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
|
Admin
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
|
{navGroups.map((group) => {
|
|
const links = group.items.map((n) => (
|
|
<NavLink
|
|
key={n.to}
|
|
to={n.to}
|
|
end={n.end}
|
|
className="admin-nav-link"
|
|
style={({ isActive }) => ({
|
|
...navBtnBase,
|
|
background: isActive ? 'var(--blue)' : 'transparent',
|
|
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
|
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
|
})}
|
|
>
|
|
{n.icon && <n.icon />}
|
|
<span>{n.label}</span>
|
|
</NavLink>
|
|
))
|
|
|
|
// Untitled groups (Dashboard, Account) render their links directly.
|
|
if (!group.title) {
|
|
return (
|
|
<div key={group.items[0]?.to || 'group'} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{links}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Titled groups get a collapsible header. The group with the active
|
|
// route stays open regardless of the stored collapse preference.
|
|
const isOpen = group.title === activeGroupTitle || !collapsed[group.title]
|
|
return (
|
|
<div key={group.title} className="admin-nav-group">
|
|
<button
|
|
type="button"
|
|
className="admin-nav-head sans"
|
|
onClick={() => toggleGroup(group.title)}
|
|
aria-expanded={isOpen}
|
|
>
|
|
<span>{group.title}</span>
|
|
<svg
|
|
className="admin-nav-chev"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
{isOpen && (
|
|
<div className="admin-nav-items" style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{links}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
|
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: '0.78rem', color: 'var(--muted)' }}>
|
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: modeDot, boxShadow: `0 0 8px ${modeDot}` }} />
|
|
Site is <strong style={{ color: 'var(--ink)', textTransform: 'capitalize' }}>{mode}</strong>
|
|
</div>
|
|
<button
|
|
onClick={signOut}
|
|
className="sans"
|
|
style={{ display: 'block', width: '100%', textAlign: 'center', border: '1px solid var(--line)', borderRadius: 8, padding: 9, color: 'var(--muted)', background: 'transparent', fontSize: '0.84rem', cursor: 'pointer' }}
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<main style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
<header
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 16,
|
|
padding: '20px 32px',
|
|
borderBottom: '1px solid var(--line-soft)',
|
|
background: 'var(--bg)',
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
|
|
{title}
|
|
</h1>
|
|
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
|
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
|
View site →
|
|
</a>
|
|
<span
|
|
style={{ width: 30, height: 30, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '0.8rem', textTransform: 'uppercase' }}
|
|
>
|
|
{(user?.username || 'A').charAt(0)}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: wide ? 'none' : 1000, width: '100%' }}>
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|