feat(nextjs): 5-level layout system, sidebar navigation, God Panel, DBAL integration

- AppShell with level-based auth gating (Guest through SuperGod)
- Sidebar with static core items + dynamic DBAL package navigation
- God Panel with 10 tabs: schemas, workflows, packages, users, DB, etc.
- Super God Panel with multi-tenant control
- Admin panel with entity browser
- JSON-driven config (sidebar-config.json, god-panel-config.json)
- DBAL health banner and graceful offline fallbacks
- Workflow editor integration via existing WorkflowBuilder component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 03:16:47 +00:00
parent 75791eb2f3
commit d17e355b7f
22 changed files with 3008 additions and 16 deletions

View File

@@ -0,0 +1,205 @@
/**
* Admin Panel Page (Level 2+: Moderator/Admin)
*
* Mirrors old/src/components/Level3.tsx admin panel
* Django-style data management with CRUD for users and entities
* Reads entity schemas from DBAL and renders generic tables
*/
'use client'
import { useState, useEffect, useCallback } from 'react'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Button,
TextField,
Avatar,
Chip,
Tabs,
Tab,
TabPanel,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@/fakemui'
interface UserRecord {
id: string
username: string
email: string
role: string
createdAt: string
}
interface EntityStats {
label: string
count: number
icon: string
}
function AdminContent() {
const [activeTab, setActiveTab] = useState(0)
const [search, setSearch] = useState('')
const [users, setUsers] = useState<UserRecord[]>([])
const [stats, setStats] = useState<EntityStats[]>([
{ label: 'Total Users', count: 0, icon: 'U' },
{ label: 'Total Comments', count: 0, icon: 'C' },
{ label: 'Admin Users', count: 0, icon: 'A' },
])
useEffect(() => {
// Load users from DBAL
const dbalUrl = process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080'
fetch(`${dbalUrl}/system/core/user`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: UserRecord[] } | null) => {
if (json?.data != null) {
setUsers(json.data)
setStats([
{ label: 'Total Users', count: json.data.length, icon: 'U' },
{ label: 'Total Comments', count: 0, icon: 'C' },
{ label: 'Admin Users', count: json.data.filter(u => u.role === 'admin' || u.role === 'god').length, icon: 'A' },
])
}
})
.catch(() => {
// Fallback seed data for offline mode
setUsers([
{ id: '1', username: 'demo', email: 'demo@metabuilder.dev', role: 'user', createdAt: new Date().toISOString() },
{ id: '2', username: 'admin', email: 'admin@metabuilder.dev', role: 'admin', createdAt: new Date().toISOString() },
])
setStats([
{ label: 'Total Users', count: 2, icon: 'U' },
{ label: 'Total Comments', count: 0, icon: 'C' },
{ label: 'Admin Users', count: 1, icon: 'A' },
])
})
}, [])
const filteredUsers = users.filter(u =>
u.username.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
)
const handleDeleteUser = useCallback((userId: string) => {
setUsers(prev => prev.filter(u => u.id !== userId))
}, [])
return (
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Typography variant="h4" gutterBottom>Data Management</Typography>
<Typography variant="body1" color="text.secondary">
Manage all application data and users
</Typography>
</div>
{/* Stats cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 }}>
{stats.map(stat => (
<Paper key={stat.label} sx={{ p: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<Typography variant="body2" color="text.secondary">{stat.label}</Typography>
<Avatar sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>{stat.icon}</Avatar>
</div>
<Typography variant="h5" sx={{ fontWeight: 700 }}>{stat.count}</Typography>
</Paper>
))}
</div>
{/* Data tables */}
<Paper sx={{ p: 0 }}>
<div style={{ padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography variant="h6">Models</Typography>
<Typography variant="body2" color="text.secondary">Browse and manage data models</Typography>
</div>
<TextField
placeholder="Search..."
value={search}
onChange={(e) => { setSearch(e.target.value) }}
size="small"
sx={{ width: 256 }}
/>
</div>
<Divider />
<Tabs value={activeTab} onChange={(_e, v) => { setActiveTab(v as number) }} sx={{ px: 2, pt: 1 }}>
<Tab label={`Users (${users.length})`} />
<Tab label="Comments (0)" />
<Tab label="Entities" />
</Tabs>
<TabPanel value={activeTab} index={0}>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">No users found</Typography>
</TableCell>
</TableRow>
) : (
filteredUsers.map(u => (
<TableRow key={u.id}>
<TableCell sx={{ fontWeight: 500 }}>{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Chip
label={u.role}
size="small"
color={u.role === 'god' ? 'secondary' : u.role === 'admin' ? 'primary' : 'default'}
/>
</TableCell>
<TableCell>{new Date(u.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
<Button variant="text" size="small" onClick={() => { handleDeleteUser(u.id) }}>
Delete
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No comments data available. Connect to DBAL to load comments.
</Typography>
</TabPanel>
<TabPanel value={activeTab} index={2}>
<Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Entity schemas are loaded from DBAL at dbal/shared/api/schema/entities/. Connect to DBAL to browse entities.
</Typography>
</TabPanel>
</Paper>
</div>
)
}
export default function AdminPage() {
return (
<LevelGate minLevel={2} levelName="Admin">
<AdminContent />
</LevelGate>
)
}

View File

@@ -0,0 +1,158 @@
/**
* Comments Page (Level 1+: User)
*
* Mirrors old/src/components/Level2.tsx comments tab
* Community discussion with post/delete capabilities
*/
'use client'
import { useState } from 'react'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Button,
TextField,
Avatar,
Divider,
Chip,
} from '@/fakemui'
interface Comment {
id: string
userId: string
username: string
content: string
createdAt: number
}
function CommentsContent() {
const auth = useAuthContext()
const user = auth.user
const [newComment, setNewComment] = useState('')
const [comments, setComments] = useState<Comment[]>([
{
id: 'demo_1',
userId: 'demo',
username: 'demo',
content: 'Welcome to MetaBuilder! This is a sample comment.',
createdAt: Date.now() - 86400000,
},
])
const handlePost = () => {
if (newComment.trim() === '') return
const comment: Comment = {
id: `comment_${Date.now()}`,
userId: user?.id ?? 'unknown',
username: user?.username ?? 'Anonymous',
content: newComment,
createdAt: Date.now(),
}
setComments(prev => [...prev, comment])
setNewComment('')
}
const handleDelete = (id: string) => {
setComments(prev => prev.filter(c => c.id !== id))
}
const myComments = comments.filter(c => c.userId === user?.id)
return (
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
Comments
</Typography>
{/* Post form */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>Post a Comment</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Share your thoughts with the community
</Typography>
<TextField
value={newComment}
onChange={(e) => { setNewComment(e.target.value) }}
placeholder="Write your comment here..."
fullWidth
multiline
rows={3}
size="small"
sx={{ mb: 2 }}
/>
<Button variant="contained" size="small" onClick={handlePost}>
Post Comment
</Button>
</Paper>
{/* My comments */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Your Comments ({myComments.length})
</Typography>
{myComments.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
You have not posted any comments yet
</Typography>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{myComments.map(comment => (
<Paper key={comment.id} variant="outlined" sx={{ p: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2">{comment.content}</Typography>
<Button variant="text" size="small" color="error" onClick={() => { handleDelete(comment.id) }}>
Delete
</Button>
</div>
<Typography variant="caption" color="text.secondary">
{new Date(comment.createdAt).toLocaleString()}
</Typography>
</Paper>
))}
</div>
)}
</Paper>
{/* All comments */}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
All Comments ({comments.length})
</Typography>
{comments.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No comments yet. Be the first to post!
</Typography>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{comments.map(comment => (
<Paper key={comment.id} variant="outlined" sx={{ p: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Avatar sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
{comment.username.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{comment.username}
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(comment.createdAt).toLocaleDateString()}
</Typography>
</div>
<Typography variant="body2">{comment.content}</Typography>
</Paper>
))}
</div>
)}
</Paper>
</div>
)
}
export default function CommentsPage() {
return (
<LevelGate minLevel={1} levelName="User">
<CommentsContent />
</LevelGate>
)
}

View File

@@ -0,0 +1,164 @@
/**
* Dashboard Page (Level 1+: User)
*
* Mirrors old/src/components/Level2.tsx user dashboard
* Shows user-specific content: profile summary, recent activity, quick actions
*/
'use client'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Button,
Avatar,
Chip,
Divider,
} from '@/fakemui'
import { getRoleLevel } from '@/lib/constants'
import { getLevelLabel, getLevelColor } from '@/lib/packages/navigation'
import Link from 'next/link'
function DashboardContent() {
const auth = useAuthContext()
const user = auth.user
const userLevel = getRoleLevel(user?.role ?? 'user')
const levelColor = getLevelColor(userLevel)
return (
<div style={{ maxWidth: 900, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
User Dashboard
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Welcome back, {user?.username ?? user?.name ?? 'User'}
</Typography>
{/* Profile summary card */}
<Paper sx={{ p: 3, mb: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<Avatar sx={{ width: 64, height: 64, bgcolor: levelColor, fontSize: '1.5rem' }}>
{(user?.username ?? 'U').charAt(0).toUpperCase()}
</Avatar>
<div>
<Typography variant="h6">{user?.username ?? user?.name}</Typography>
<Typography variant="body2" color="text.secondary">
{user?.email}
</Typography>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Chip
label={`Level ${userLevel} - ${getLevelLabel(userLevel)}`}
size="small"
sx={{ bgcolor: levelColor, color: '#fff' }}
/>
<Chip label={user?.role ?? 'user'} size="small" variant="outlined" />
</div>
</div>
</div>
{user?.bio != null && user.bio !== '' && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="body2" color="text.secondary">
{user.bio}
</Typography>
</>
)}
</Paper>
{/* Quick actions */}
<Typography variant="h6" gutterBottom>
Quick Actions
</Typography>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="subtitle2" gutterBottom>Profile</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Edit your profile information
</Typography>
<Button component={Link} href="/app/profile" variant="outlined" size="small">
Edit Profile
</Button>
</Paper>
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="subtitle2" gutterBottom>Comments</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
View community discussion
</Typography>
<Button component={Link} href="/app/comments" variant="outlined" size="small">
View Comments
</Button>
</Paper>
{userLevel >= 2 && (
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="subtitle2" gutterBottom>Admin Panel</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Manage users and data
</Typography>
<Button component={Link} href="/app/admin" variant="outlined" size="small">
Admin Panel
</Button>
</Paper>
)}
{userLevel >= 4 && (
<Paper sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="subtitle2" gutterBottom>God Panel</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Application builder tools
</Typography>
<Button component={Link} href="/app/god-panel" variant="outlined" size="small">
God Panel
</Button>
</Paper>
)}
</div>
{/* Five levels overview (from Level1.tsx) */}
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>
Five Levels of Power
</Typography>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
{[
{ level: 1, name: 'Public Website', desc: 'Landing pages, public content', color: '#2196f3' },
{ level: 2, name: 'User Area', desc: 'Profiles, comments, chat', color: '#4caf50' },
{ level: 3, name: 'Admin Panel', desc: 'CRUD, user management', color: '#ff9800' },
{ level: 4, name: 'God Builder', desc: 'Schemas, workflows, Lua', color: '#9c27b0' },
{ level: 5, name: 'Super God', desc: 'Multi-tenant control', color: '#ffc107' },
].map(item => (
<Paper
key={item.level}
sx={{
p: 2,
borderLeft: `4px solid ${item.color}`,
opacity: userLevel >= item.level ? 1 : 0.5,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Avatar sx={{ width: 28, height: 28, bgcolor: item.color, fontSize: '0.75rem' }}>
{item.level}
</Avatar>
<Typography variant="subtitle2">{item.name}</Typography>
</div>
<Typography variant="caption" color="text.secondary">
{item.desc}
</Typography>
{userLevel >= item.level && (
<Chip label="Unlocked" size="small" sx={{ mt: 1, bgcolor: item.color, color: '#fff', height: 20, fontSize: '0.65rem' }} />
)}
</Paper>
))}
</div>
</div>
)
}
export default function DashboardPage() {
return (
<LevelGate minLevel={1} levelName="User">
<DashboardContent />
</LevelGate>
)
}

View File

@@ -0,0 +1,557 @@
/**
* God Panel Page (Level 4: God)
*
* Mirrors old/src/components/Level4.tsx and Qt6 god-panel package
* The meta-builder with:
* - Tab-based tool interface (from god-panel-config.json)
* - Schema editor, workflow builder, package manager
* - Database management, credential settings, theme editor
* - Preview levels, import/export
*
* Tools are configured via god-panel-config.json (95% data, 5% code)
*/
'use client'
import { useState, useEffect, Suspense } from 'react'
import { LevelGate } from '@/components/layout/LevelGate'
import { godPanelConfig } from '@/lib/packages/navigation'
import type { GodPanelTab } from '@/lib/packages/navigation'
import {
Typography,
Paper,
Button,
Tabs,
Tab,
TabPanel,
Chip,
Divider,
Avatar,
TextField,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@/fakemui'
/** Lazy-load the existing WorkflowBuilder if available */
const WorkflowBuilderLazy = typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-require-imports
require('@/components/workflow/WorkflowBuilder').WorkflowBuilder
: null
const DBAL_URL = typeof process !== 'undefined'
? (process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080')
: 'http://localhost:8080'
/** Tab icon mapping to single-char avatars */
const tabIconMap: Record<string, string> = {
dashboard: 'O',
database: 'D',
workflow: 'W',
package: 'P',
pages: 'R',
components: 'C',
people: 'U',
storage: 'S',
key: 'K',
palette: 'T',
}
interface EntitySchema {
name: string
fields: Array<{ name: string; type: string }>
}
interface DbalStatus {
connected: boolean
version?: string
uptime?: string
adapters?: string[]
}
function OverviewTab() {
const [dbalStatus, setDbalStatus] = useState<DbalStatus>({ connected: false })
const [entityCount, setEntityCount] = useState(0)
useEffect(() => {
// Check DBAL status
fetch(`${DBAL_URL}/health`, { signal: AbortSignal.timeout(3000) })
.then(res => res.ok ? res.json() : null)
.then((json) => {
if (json != null) {
setDbalStatus({
connected: true,
version: (json as Record<string, string>).version ?? 'unknown',
uptime: (json as Record<string, string>).uptime ?? 'unknown',
})
}
})
.catch(() => { setDbalStatus({ connected: false }) })
// Count entities
fetch(`${DBAL_URL}/version`, { signal: AbortSignal.timeout(3000) })
.then(res => res.ok ? res.json() : null)
.then((json) => {
if (json != null && typeof (json as Record<string, unknown>).entityCount === 'number') {
setEntityCount((json as Record<string, number>).entityCount)
}
})
.catch(() => { /* ignore */ })
}, [])
return (
<div>
<Typography variant="h6" gutterBottom>System Overview</Typography>
{/* DBAL Status */}
<Paper sx={{ p: 2, mb: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div
style={{
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: dbalStatus.connected ? '#4caf50' : '#f44336',
}}
/>
<Typography variant="subtitle2">
DBAL Daemon: {dbalStatus.connected ? 'Connected' : 'Offline'}
</Typography>
</div>
{dbalStatus.connected && (
<div style={{ display: 'flex', gap: 16 }}>
{dbalStatus.version != null && <Chip label={`v${dbalStatus.version}`} size="small" />}
{dbalStatus.uptime != null && <Chip label={`Up: ${dbalStatus.uptime}`} size="small" variant="outlined" />}
<Chip label={`${entityCount} entities`} size="small" variant="outlined" />
</div>
)}
</Paper>
{/* Quick tools grid */}
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>Quick Actions</Typography>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
{godPanelConfig.tools.map(tool => (
<Paper key={tool.id} sx={{ p: 2, textAlign: 'center', cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}>
<Avatar sx={{ width: 32, height: 32, mx: 'auto', mb: 1 }}>
{tool.icon.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="body2">{tool.label}</Typography>
</Paper>
))}
</div>
{/* Config summary (from Level4.tsx) */}
<Paper
sx={{
mt: 3,
p: 3,
background: 'linear-gradient(135deg, rgba(98,0,238,0.08), rgba(3,218,198,0.08))',
border: '2px dashed rgba(98,0,238,0.3)',
borderRadius: 2,
}}
>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600 }}>
Configuration Summary
</Typography>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">DBAL Entities:</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{entityCount}</Typography>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">God Panel Tabs:</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{godPanelConfig.tabs.length}</Typography>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Quick Tools:</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{godPanelConfig.tools.length}</Typography>
</div>
</div>
</Paper>
</div>
)
}
function SchemasTab() {
const [schemas, setSchemas] = useState<EntitySchema[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`${DBAL_URL}/system/core/entity_schema`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: EntitySchema[] } | null) => {
if (json?.data != null) setSchemas(json.data)
setLoading(false)
})
.catch(() => {
// Fallback: show known entity categories
setSchemas([
{ name: 'user', fields: [{ name: 'id', type: 'uuid' }, { name: 'username', type: 'string' }, { name: 'email', type: 'string' }, { name: 'role', type: 'string' }] },
{ name: 'session', fields: [{ name: 'id', type: 'uuid' }, { name: 'userId', type: 'uuid' }, { name: 'token', type: 'string' }] },
{ name: 'workflow', fields: [{ name: 'id', type: 'uuid' }, { name: 'name', type: 'string' }, { name: 'version', type: 'string' }] },
{ name: 'package', fields: [{ name: 'id', type: 'uuid' }, { name: 'packageId', type: 'string' }, { name: 'name', type: 'string' }] },
{ name: 'ui_page', fields: [{ name: 'id', type: 'uuid' }, { name: 'path', type: 'string' }, { name: 'title', type: 'string' }] },
])
setLoading(false)
})
}, [])
if (loading) {
return <Typography variant="body2" color="text.secondary">Loading schemas...</Typography>
}
return (
<div>
<Typography variant="h6" gutterBottom>Entity Schemas</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Source of truth: dbal/shared/api/schema/entities/
</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Entity</TableCell>
<TableCell>Fields</TableCell>
<TableCell>Field Names</TableCell>
</TableRow>
</TableHead>
<TableBody>
{schemas.map(schema => (
<TableRow key={schema.name}>
<TableCell sx={{ fontWeight: 500 }}>{schema.name}</TableCell>
<TableCell>{schema.fields.length}</TableCell>
<TableCell>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{schema.fields.map(f => (
<Chip
key={f.name}
label={`${f.name}: ${f.type}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.65rem', height: 22 }}
/>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
)
}
function WorkflowsTab() {
return (
<div>
<Typography variant="h6" gutterBottom>Workflow Builder</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Visual DAG workflow editor. Define workflows in JSON with version 2.2.0 format.
</Typography>
{WorkflowBuilderLazy != null ? (
<Suspense fallback={<Typography variant="body2">Loading workflow editor...</Typography>}>
<Paper sx={{ p: 2, height: 500 }}>
<WorkflowBuilderLazy
workflow={{
id: 'wf_sample',
name: 'Sample Workflow',
version: '2.2.0',
nodes: [
{ id: 'trigger', name: 'Trigger', nodeType: 'trigger', position: [50, 100], parameters: {}, description: 'Workflow entry point' },
{ id: 'process', name: 'Process', nodeType: 'function', position: [300, 100], parameters: {}, description: 'Process data' },
{ id: 'output', name: 'Output', nodeType: 'output', position: [550, 100], parameters: {}, description: 'Return result' },
],
connections: {
trigger: { default: { '0': [{ node: 'process', port: 'input', index: 0 }] } },
process: { default: { '0': [{ node: 'output', port: 'input', index: 0 }] } },
},
variables: {},
settings: { debugMode: false, maxConcurrentExecutions: 1, executionTimeout: 30000 },
}}
tenant="system"
readOnly={false}
/>
</Paper>
</Suspense>
) : (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
Workflow editor component available at /components/workflow/WorkflowBuilder.tsx
</Typography>
<Typography variant="body2" color="text.secondary">
Also see frontends/workflowui/ for the full React workflow editor with n8n-style visual editor.
</Typography>
<Divider sx={{ my: 2 }} />
<Typography variant="body2" color="text.secondary">
Workflows are defined in dbal/shared/api/schema/workflows/ as JSON files.
Event dispatch: entity route handler &rarr; send_success callback &rarr; WfEngine &rarr; detached thread execution.
</Typography>
</Paper>
)}
</div>
)
}
function PackagesTab() {
const [packages, setPackages] = useState<Array<{ packageId: string; name: string; version: string; category: string; level: number }>>([])
useEffect(() => {
fetch(`${DBAL_URL}/system/core/package`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: Array<Record<string, unknown>> } | null) => {
if (json?.data != null) {
setPackages(json.data as Array<{ packageId: string; name: string; version: string; category: string; level: number }>)
}
})
.catch(() => {
// Hardcoded from Qt6 package metadata for offline
setPackages([
{ packageId: 'analytics', name: 'Analytics Studio', version: '1.0.0', category: 'admin', level: 3 },
{ packageId: 'blog', name: 'Blog', version: '1.0.0', category: 'social', level: 2 },
{ packageId: 'forum', name: 'Community Forum', version: '1.0.0', category: 'social', level: 2 },
{ packageId: 'gallery', name: 'Gallery', version: '1.0.0', category: 'media', level: 2 },
{ packageId: 'guestbook', name: 'Guestbook', version: '1.0.0', category: 'social', level: 2 },
{ packageId: 'god_panel', name: 'God Panel', version: '1.0.0', category: 'admin', level: 4 },
{ packageId: 'package_manager', name: 'Package Manager', version: '1.0.0', category: 'admin', level: 4 },
])
})
}, [])
return (
<div>
<Typography variant="h6" gutterBottom>Package Manager</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
62 packages across Admin, UI Core, Dev Tools, Features, Testing categories.
</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Package</TableCell>
<TableCell>Version</TableCell>
<TableCell>Category</TableCell>
<TableCell>Level</TableCell>
</TableRow>
</TableHead>
<TableBody>
{packages.map(pkg => (
<TableRow key={pkg.packageId}>
<TableCell sx={{ fontWeight: 500 }}>{pkg.name}</TableCell>
<TableCell><Chip label={pkg.version} size="small" variant="outlined" sx={{ fontSize: '0.65rem' }} /></TableCell>
<TableCell><Chip label={pkg.category} size="small" /></TableCell>
<TableCell>{pkg.level}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
)
}
function PageRoutesTab() {
return (
<div>
<Typography variant="h6" gutterBottom>Page Routes</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
URL route configuration. Routes map to JSON component trees rendered by UIPageRenderer.
</Typography>
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Routes are stored in the database and loaded by /ui/[[...slug]]/page.tsx via loadPageFromDb().
Create routes using the DBAL API: POST /system/core/page_config
</Typography>
</Paper>
</div>
)
}
function ComponentsTab() {
return (
<div>
<Typography variant="h6" gutterBottom>Component Hierarchy</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
JSON-defined component trees. Components render via JSONComponentRenderer.
</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Available component categories:
</Typography>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{['layout', 'cards', 'forms', 'navigation', 'feedback', 'data-display', 'surfaces', 'atoms', 'core'].map(cat => (
<Chip key={cat} label={cat} size="small" />
))}
</div>
</Paper>
</div>
)
}
function UsersTab() {
return (
<div>
<Typography variant="h6" gutterBottom>User Management</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Manage users, roles, and permissions. Role hierarchy: user &rarr; moderator &rarr; admin &rarr; god &rarr; supergod
</Typography>
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
User management is available in the Admin Panel tab. This tab provides advanced role assignment
and multi-tenant user configuration.
</Typography>
</Paper>
</div>
)
}
function DatabaseTab() {
return (
<div>
<Typography variant="h6" gutterBottom>Database Management</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
14 database backends supported. Configure via DATABASE_URL environment variable.
</Typography>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
{[
{ name: 'Memory', desc: 'In-memory (testing)' },
{ name: 'SQLite', desc: 'Embedded, generic CRUD' },
{ name: 'PostgreSQL', desc: 'Direct connection' },
{ name: 'MySQL', desc: 'Direct connection' },
{ name: 'MongoDB', desc: 'JSON/BSON' },
{ name: 'Redis', desc: 'Cache layer' },
{ name: 'Elasticsearch', desc: 'Full-text search' },
{ name: 'SurrealDB', desc: 'Multi-model' },
].map(db => (
<Paper key={db.name} sx={{ p: 2 }}>
<Typography variant="subtitle2">{db.name}</Typography>
<Typography variant="caption" color="text.secondary">{db.desc}</Typography>
</Paper>
))}
</div>
</div>
)
}
function CredentialsTab() {
return (
<div>
<Typography variant="h6" gutterBottom>Credential Management</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
God-tier credential management. JWT auth + JSON ACL configuration.
</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="subtitle2" gutterBottom>JWT Authentication</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Auth config: DBAL_AUTH_CONFIG=/app/schemas/auth/auth.json
</Typography>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>Seed Users</Typography>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Chip label="demo / demo (user, L2)" size="small" variant="outlined" />
<Chip label="admin / admin (admin, L3)" size="small" variant="outlined" />
<Chip label="god / god123 (god, L4)" size="small" variant="outlined" />
<Chip label="super / super123 (supergod, L5)" size="small" variant="outlined" />
</div>
</Paper>
</div>
)
}
function ThemeTab() {
return (
<div>
<Typography variant="h6" gutterBottom>Theme Configuration</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Configure application theme, colors, and typography.
</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary">
Theme is managed via CSS custom properties and the theme context provider.
Toggle between light/dark/system using the AppBar theme button.
</Typography>
</Paper>
</div>
)
}
/** Map tab IDs to their content components */
const tabComponents: Record<string, React.FC> = {
overview: OverviewTab,
schemas: SchemasTab,
workflows: WorkflowsTab,
packages: PackagesTab,
pages: PageRoutesTab,
components: ComponentsTab,
users: UsersTab,
database: DatabaseTab,
credentials: CredentialsTab,
theme: ThemeTab,
}
function GodPanelContent() {
const [activeTab, setActiveTab] = useState(0)
const tabs = godPanelConfig.tabs
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Typography variant="h4" gutterBottom>
Application Builder
</Typography>
<Typography variant="body1" color="text.secondary">
Design your application declaratively. Define schemas, create workflows, manage packages and pages.
</Typography>
</div>
<Tabs
value={activeTab}
onChange={(_e, v) => { setActiveTab(v as number) }}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
>
{tabs.map((tab: GodPanelTab) => (
<Tab
key={tab.id}
label={tab.label}
icon={
<Avatar sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
{tabIconMap[tab.icon] ?? tab.icon.charAt(0).toUpperCase()}
</Avatar>
}
iconPosition="start"
sx={{ textTransform: 'none', minHeight: 48 }}
/>
))}
</Tabs>
{tabs.map((tab: GodPanelTab, index: number) => {
const TabComponent = tabComponents[tab.id]
return (
<TabPanel key={tab.id} value={activeTab} index={index}>
{TabComponent != null ? <TabComponent /> : (
<Typography variant="body2" color="text.secondary">
Tab content for &ldquo;{tab.label}&rdquo; is not yet implemented.
</Typography>
)}
</TabPanel>
)
})}
</div>
)
}
export default function GodPanelPage() {
return (
<LevelGate minLevel={4} levelName="God">
<GodPanelContent />
</LevelGate>
)
}

View File

@@ -0,0 +1,26 @@
/**
* App Layout
*
* Wraps all /app/* routes with the AppShell (sidebar + appbar).
* This is the authenticated area of the application.
* The AppShell provides:
* - AppBar with level navigation, DBAL status, theme toggle
* - Sidebar with level-filtered nav items and dynamic packages
* - Main content area
*
* Mirrors the Qt6 App.qml RowLayout structure.
*/
'use client'
import { AuthProvider } from '@/app/_components/auth-provider'
import { AppShell } from '@/components/layout'
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<AppShell>
{children}
</AppShell>
</AuthProvider>
)
}

View File

@@ -0,0 +1,150 @@
/**
* Dynamic Package Page
*
* Renders a package view based on the packageId from the URL.
* Mirrors Qt6 PackageViewLoader which loads QML views dynamically.
*
* Packages are loaded from DBAL or the local package catalog.
*/
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Chip,
Avatar,
Divider,
Button,
} from '@/fakemui'
const DBAL_URL = typeof process !== 'undefined'
? (process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080')
: 'http://localhost:8080'
interface PackageMetadata {
packageId: string
name: string
version: string
description: string
dependencies: string[]
level: number
category: string
icon: string
}
function PackageContent({ packageId }: { packageId: string }) {
const [metadata, setMetadata] = useState<PackageMetadata | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`${DBAL_URL}/system/core/package/${packageId}`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: PackageMetadata } | null) => {
if (json?.data != null) setMetadata(json.data)
setLoading(false)
})
.catch(() => {
setMetadata({
packageId,
name: packageId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
version: '1.0.0',
description: `Package: ${packageId}`,
dependencies: [],
level: 2,
category: 'general',
icon: packageId.charAt(0).toUpperCase(),
})
setLoading(false)
})
}, [packageId])
if (loading) {
return <Typography variant="body2" color="text.secondary">Loading package...</Typography>
}
if (metadata == null) {
return (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6">Package Not Found</Typography>
<Typography variant="body2" color="text.secondary">
Package &ldquo;{packageId}&rdquo; could not be loaded.
</Typography>
</Paper>
)
}
return (
<div style={{ maxWidth: 700, margin: '0 auto' }}>
{/* Package header */}
<Paper sx={{ p: 3, mb: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<Avatar sx={{ width: 56, height: 56, fontSize: '1.5rem' }}>
{metadata.icon}
</Avatar>
<div>
<Typography variant="h5">{metadata.name}</Typography>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Chip label={`v${metadata.version}`} size="small" />
<Chip label={metadata.category} size="small" variant="outlined" />
<Chip label={`Level ${metadata.level}`} size="small" variant="outlined" />
</div>
</div>
</div>
<Typography variant="body1" color="text.secondary">
{metadata.description}
</Typography>
</Paper>
{/* Dependencies */}
{metadata.dependencies.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>Dependencies</Typography>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{metadata.dependencies.map(dep => (
<Chip key={dep} label={dep} size="small" variant="outlined" />
))}
</div>
</Paper>
)}
{/* Package view placeholder */}
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
Package view for &ldquo;{metadata.name}&rdquo;
</Typography>
<Typography variant="body2" color="text.secondary">
Package-specific UI components are loaded dynamically.
In Qt6, this maps to PackageViewLoader with packageId: &ldquo;{packageId}&rdquo;.
</Typography>
<Divider sx={{ my: 2 }} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<Chip label="Adaptive layout" size="small" />
<Chip label="Realtime telemetry" size="small" />
<Chip label={metadata.dependencies.length > 0 ? 'Dependency package' : 'Standalone'} size="small" />
</div>
</Paper>
</div>
)
}
export default function PackagePage() {
const params = useParams()
const packageId = params?.packageId as string | undefined
if (packageId == null || packageId === '') {
return (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6">No Package Selected</Typography>
</Paper>
)
}
return (
<LevelGate minLevel={1} levelName="User">
<PackageContent packageId={packageId} />
</LevelGate>
)
}

View File

@@ -0,0 +1,52 @@
/**
* App Root Page
*
* Entry point for /app - redirects to dashboard if authenticated,
* or shows a sign-in prompt.
*/
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { Typography, Paper, Button } from '@/fakemui'
export default function AppRootPage() {
const auth = useAuthContext()
const router = useRouter()
useEffect(() => {
if (auth.isAuthenticated && !auth.isLoading) {
router.replace('/app/dashboard')
}
}, [auth.isAuthenticated, auth.isLoading, router])
if (auth.isLoading) {
return null
}
if (auth.isAuthenticated) {
return null // Will redirect
}
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
<Paper sx={{ p: 4, textAlign: 'center', maxWidth: 480 }}>
<Typography variant="h4" gutterBottom>
MetaBuilder
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sign in to access the application builder, admin tools, and your dashboard.
</Typography>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<Button variant="contained" onClick={() => { router.push('/app/ui/login') }}>
Sign In
</Button>
<Button variant="outlined" onClick={() => { router.push('/') }}>
Back to Home
</Button>
</div>
</Paper>
</div>
)
}

View File

@@ -0,0 +1,134 @@
/**
* Profile Page (Level 1+: User)
*
* Mirrors old/src/components/Level2.tsx profile tab
* User can view and edit their profile information
*/
'use client'
import { useState } from 'react'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Button,
TextField,
Avatar,
Divider,
} from '@/fakemui'
import { getRoleLevel } from '@/lib/constants'
import { getLevelColor } from '@/lib/packages/navigation'
function ProfileContent() {
const auth = useAuthContext()
const user = auth.user
const userLevel = getRoleLevel(user?.role ?? 'user')
const levelColor = getLevelColor(userLevel)
const [editing, setEditing] = useState(false)
const [bio, setBio] = useState(user?.bio ?? '')
const [email, setEmail] = useState(user?.email ?? '')
const handleSave = () => {
// In production, this would call the DBAL API to update the user
setEditing(false)
}
return (
<div style={{ maxWidth: 680, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
Profile
</Typography>
<Paper sx={{ p: 3 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<Typography variant="h6">Profile Information</Typography>
{!editing ? (
<Button variant="outlined" size="small" onClick={() => { setEditing(true) }}>
Edit Profile
</Button>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="outlined" size="small" onClick={() => { setEditing(false) }}>
Cancel
</Button>
<Button variant="contained" size="small" onClick={handleSave}>
Save
</Button>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
<Avatar sx={{ width: 80, height: 80, bgcolor: levelColor, fontSize: '2rem' }}>
{(user?.username ?? 'U').charAt(0).toUpperCase()}
</Avatar>
<div>
<Typography variant="h6">{user?.username}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ textTransform: 'capitalize' }}>
{user?.role} Account
</Typography>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<TextField
label="Username"
value={user?.username ?? ''}
disabled
fullWidth
size="small"
/>
<TextField
label="Email"
type="email"
value={editing ? email : (user?.email ?? '')}
onChange={(e) => { setEmail(e.target.value) }}
disabled={!editing}
fullWidth
size="small"
/>
<TextField
label="Bio"
value={editing ? bio : (user?.bio ?? '')}
onChange={(e) => { setBio(e.target.value) }}
disabled={!editing}
fullWidth
multiline
rows={4}
size="small"
placeholder="Tell us about yourself..."
/>
<TextField
label="Account Created"
value="--"
disabled
fullWidth
size="small"
/>
</div>
<Divider sx={{ my: 3 }} />
<Typography variant="subtitle2" gutterBottom>
Security
</Typography>
<Button variant="outlined" size="small">
Request New Password via Email
</Button>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
A new randomly generated password will be sent to your email address
</Typography>
</Paper>
</div>
)
}
export default function ProfilePage() {
return (
<LevelGate minLevel={1} levelName="User">
<ProfileContent />
</LevelGate>
)
}

View File

@@ -0,0 +1,105 @@
/**
* Settings Page (Level 1+: User)
*
* Mirrors the Qt6 sidebar "Settings" item
* User-facing settings: theme, notifications, account
*/
'use client'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { useTheme } from '@/app/providers'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Button,
Switch,
Divider,
Chip,
} from '@/fakemui'
function SettingsContent() {
const auth = useAuthContext()
const { mode, setMode, resolvedMode } = useTheme()
return (
<div style={{ maxWidth: 680, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>Settings</Typography>
{/* Theme */}
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="h6" gutterBottom>Appearance</Typography>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div>
<Typography variant="body2">Theme Mode</Typography>
<Typography variant="caption" color="text.secondary">
Current: {resolvedMode} (preference: {mode})
</Typography>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{(['light', 'dark', 'system'] as const).map(m => (
<Chip
key={m}
label={m}
size="small"
variant={mode === m ? 'filled' : 'outlined'}
onClick={() => { setMode(m) }}
sx={{ cursor: 'pointer', textTransform: 'capitalize' }}
/>
))}
</div>
</div>
</Paper>
{/* Account */}
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="h6" gutterBottom>Account</Typography>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography variant="body2">Username</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{auth.user?.username ?? 'N/A'}
</Typography>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography variant="body2">Email</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{auth.user?.email ?? 'N/A'}
</Typography>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Role</Typography>
<Chip label={auth.user?.role ?? 'user'} size="small" />
</div>
<Divider sx={{ my: 2 }} />
<Button
variant="outlined"
color="error"
size="small"
onClick={() => { void auth.logout() }}
>
Sign Out
</Button>
</Paper>
{/* DBAL */}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>DBAL Connection</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
API URL: {process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080'}
</Typography>
<Typography variant="caption" color="text.secondary">
Data is persisted client-side via Redux + redux-persist (IndexedDB).
Server data fetched from DBAL C++ daemon REST API.
</Typography>
</Paper>
</div>
)
}
export default function SettingsPage() {
return (
<LevelGate minLevel={1} levelName="User">
<SettingsContent />
</LevelGate>
)
}

View File

@@ -0,0 +1,394 @@
/**
* Super God Panel Page (Level 5: SuperGod)
*
* Mirrors old/src/components/Level5.tsx
* Multi-tenant control center:
* - Tenant management (create, delete, assign homepages)
* - God-level user overview
* - Power transfer mechanism
* - Level preview
*/
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { LevelGate } from '@/components/layout/LevelGate'
import {
Typography,
Paper,
Button,
TextField,
Avatar,
Chip,
Tabs,
Tab,
TabPanel,
Divider,
} from '@/fakemui'
const DBAL_URL = typeof process !== 'undefined'
? (process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080')
: 'http://localhost:8080'
interface Tenant {
id: string
name: string
ownerId: string
createdAt: number
homepageConfig?: { pageId: string }
}
interface GodUser {
id: string
username: string
email: string
role: string
}
function TenantsTab() {
const [tenants, setTenants] = useState<Tenant[]>([])
const [newTenantName, setNewTenantName] = useState('')
const [showCreate, setShowCreate] = useState(false)
const auth = useAuthContext()
useEffect(() => {
fetch(`${DBAL_URL}/system/core/tenant`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: Tenant[] } | null) => {
if (json?.data != null) setTenants(json.data)
})
.catch(() => { /* offline - empty tenants */ })
}, [])
const handleCreate = () => {
if (newTenantName.trim() === '') return
const newTenant: Tenant = {
id: `tenant_${Date.now()}`,
name: newTenantName,
ownerId: auth.user?.id ?? 'unknown',
createdAt: Date.now(),
}
setTenants(prev => [...prev, newTenant])
setNewTenantName('')
setShowCreate(false)
}
const handleDelete = (id: string) => {
setTenants(prev => prev.filter(t => t.id !== id))
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<Typography variant="h6">Tenant Management</Typography>
<Typography variant="body2" color="text.secondary">
Create and manage tenants with custom homepages
</Typography>
</div>
<Button variant="contained" size="small" onClick={() => { setShowCreate(true) }}>
Create Tenant
</Button>
</div>
{showCreate && (
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Create New Tenant</Typography>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<TextField
label="Tenant Name"
value={newTenantName}
onChange={(e) => { setNewTenantName(e.target.value) }}
size="small"
fullWidth
/>
<Button variant="outlined" size="small" onClick={() => { setShowCreate(false) }}>
Cancel
</Button>
<Button variant="contained" size="small" onClick={handleCreate}>
Create
</Button>
</div>
</Paper>
)}
{tenants.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No tenants created yet. Every query must filter by tenantId (multi-tenant by default).
</Typography>
</Paper>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{tenants.map(tenant => (
<Paper key={tenant.id} sx={{ p: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{tenant.name}</Typography>
<Typography variant="caption" color="text.secondary">
Created: {new Date(tenant.createdAt).toLocaleDateString()}
</Typography>
{tenant.homepageConfig != null && (
<Chip label="Homepage Configured" size="small" color="success" sx={{ ml: 1 }} />
)}
</div>
<Button variant="outlined" size="small" color="error" onClick={() => { handleDelete(tenant.id) }}>
Delete
</Button>
</div>
</Paper>
))}
</div>
)}
</div>
)
}
function GodUsersTab() {
const [godUsers, setGodUsers] = useState<GodUser[]>([])
useEffect(() => {
fetch(`${DBAL_URL}/system/core/user`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: GodUser[] } | null) => {
if (json?.data != null) {
setGodUsers(json.data.filter(u => u.role === 'god' || u.role === 'supergod'))
}
})
.catch(() => {
setGodUsers([
{ id: '1', username: 'god', email: 'god@metabuilder.dev', role: 'god' },
{ id: '2', username: 'super', email: 'super@metabuilder.dev', role: 'supergod' },
])
})
}, [])
return (
<div>
<Typography variant="h6" gutterBottom>God-Level Users</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
All users with God access level
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{godUsers.map(user => (
<Paper key={user.id} sx={{ p: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Avatar sx={{ bgcolor: user.role === 'supergod' ? '#ffc107' : '#9c27b0' }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<div>
<Typography variant="subtitle2">{user.username}</Typography>
<Typography variant="caption" color="text.secondary">{user.email}</Typography>
</div>
</div>
<Chip
label={user.role}
size="small"
sx={{
bgcolor: user.role === 'supergod' ? '#ffc107' : '#9c27b0',
color: '#fff',
}}
/>
</div>
</Paper>
))}
</div>
</div>
)
}
function PowerTransferTab() {
const auth = useAuthContext()
const [allUsers, setAllUsers] = useState<GodUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
useEffect(() => {
fetch(`${DBAL_URL}/system/core/user`, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then((json: { data?: GodUser[] } | null) => {
if (json?.data != null) {
setAllUsers(json.data.filter(u => u.id !== auth.user?.id && u.role !== 'supergod'))
}
})
.catch(() => { /* offline */ })
}, [auth.user?.id])
return (
<div>
<Typography variant="h6" gutterBottom>Transfer Super God Power</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Transfer your Super God privileges to another user. You will be downgraded to God.
</Typography>
<Paper
sx={{
p: 2,
mb: 3,
bgcolor: 'rgba(255,152,0,0.08)',
border: '1px solid rgba(255,152,0,0.3)',
borderRadius: 1,
}}
>
<Typography variant="subtitle2" sx={{ color: '#e65100', mb: 0.5 }}>
Critical Action
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
This action cannot be undone. Only one Super God can exist at a time.
After transfer, you will have God-level access only.
</Typography>
</Paper>
<Typography variant="subtitle2" gutterBottom>Select User to Transfer Power To:</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 300, overflow: 'auto', marginBottom: 16 }}>
{allUsers.map(user => (
<Paper
key={user.id}
sx={{
p: 2,
cursor: 'pointer',
border: selectedUserId === user.id ? '2px solid #9c27b0' : '1px solid transparent',
bgcolor: selectedUserId === user.id ? 'rgba(156,39,176,0.05)' : 'inherit',
'&:hover': { bgcolor: 'action.hover' },
}}
onClick={() => { setSelectedUserId(user.id) }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography variant="subtitle2">{user.username}</Typography>
<Typography variant="caption" color="text.secondary">{user.email}</Typography>
</div>
<Chip label={user.role} size="small" variant="outlined" />
</div>
</Paper>
))}
{allUsers.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
No eligible users found. Connect to DBAL to load users.
</Typography>
)}
</div>
<Button
variant="contained"
fullWidth
disabled={selectedUserId == null}
sx={{
background: 'linear-gradient(135deg, #e65100, #ffc107)',
'&:hover': { background: 'linear-gradient(135deg, #bf360c, #f9a825)' },
}}
>
Initiate Power Transfer
</Button>
</div>
)
}
function PreviewLevelsTab() {
const router = useRouter()
const levels = [
{ level: 1, name: 'Public', desc: 'Landing page and public content', path: '/' },
{ level: 2, name: 'User Area', desc: 'User dashboard and profile', path: '/app/dashboard' },
{ level: 3, name: 'Admin Panel', desc: 'Data management interface', path: '/app/admin' },
{ level: 4, name: 'God Panel', desc: 'System builder interface', path: '/app/god-panel' },
]
return (
<div>
<Typography variant="h6" gutterBottom>Preview Application Levels</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
View how each level appears to different user roles
</Typography>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{levels.map(item => (
<Paper
key={item.level}
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => { router.push(item.path) }}
>
<Typography variant="subtitle1" gutterBottom>
Level {item.level}: {item.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{item.desc}
</Typography>
<Button variant="outlined" size="small" fullWidth>
Preview
</Button>
</Paper>
))}
</div>
</div>
)
}
function SuperGodContent() {
const [activeTab, setActiveTab] = useState(0)
return (
<div
style={{
maxWidth: 1000,
margin: '0 auto',
}}
>
{/* Header with gradient styling (mirrors Level5.tsx dark theme) */}
<Paper
sx={{
p: 3,
mb: 3,
background: 'linear-gradient(135deg, rgba(74,20,140,0.15), rgba(26,35,126,0.15))',
border: '1px solid rgba(255,193,7,0.3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Avatar sx={{ bgcolor: '#ffc107', width: 48, height: 48 }}>
<span style={{ fontSize: '1.5rem' }}>&#9813;</span>
</Avatar>
<div>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
Super God Panel
</Typography>
<Typography variant="body2" color="text.secondary">
Multi-Tenant Control Center
</Typography>
</div>
</div>
</Paper>
<Tabs
value={activeTab}
onChange={(_e, v) => { setActiveTab(v as number) }}
sx={{ mb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tab label="Tenants" sx={{ textTransform: 'none' }} />
<Tab label="God Users" sx={{ textTransform: 'none' }} />
<Tab label="Power Transfer" sx={{ textTransform: 'none' }} />
<Tab label="Preview Levels" sx={{ textTransform: 'none' }} />
</Tabs>
<TabPanel value={activeTab} index={0}>
<TenantsTab />
</TabPanel>
<TabPanel value={activeTab} index={1}>
<GodUsersTab />
</TabPanel>
<TabPanel value={activeTab} index={2}>
<PowerTransferTab />
</TabPanel>
<TabPanel value={activeTab} index={3}>
<PreviewLevelsTab />
</TabPanel>
</div>
)
}
export default function SuperGodPage() {
return (
<LevelGate minLevel={5} levelName="Super God">
<SuperGodContent />
</LevelGate>
)
}

View File

@@ -1,14 +1,25 @@
/**
* Levels data (stub)
* Levels data
*
* Aligned with ROLE_LEVELS in lib/constants.ts and Qt6 App.qml:
* - Level 0: Public (guest, no auth)
* - Level 1: User (authenticated)
* - Level 2: Moderator
* - Level 3: Admin
* - Level 4: God (meta-builder access)
* - Level 5: Super God (multi-tenant control)
*/
export const levelsData = {
public: 1,
user: 2,
moderator: 3,
admin: 4,
god: 5,
supergod: 6,
}
public: 0,
user: 1,
moderator: 2,
admin: 3,
god: 4,
supergod: 5,
} as const
export const PERMISSION_LEVELS = levelsData
export type LevelName = keyof typeof levelsData
export type LevelNumber = typeof levelsData[LevelName]

View File

@@ -1,26 +1,63 @@
'use client'
import { Container, Typography, Button, Stack, Paper } from '@/fakemui'
import { Container, Typography, Button, Stack, Paper, Chip, Avatar } from '@/fakemui'
/**
* WelcomePage - Level 1 Public Landing
*
* Mirrors old/src/components/Level1.tsx public landing page.
* Shows the 5-level architecture overview and sign-in CTAs.
*/
export function WelcomePage() {
return (
<Container {...{ maxWidth: 'sm', sx: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' } } as Record<string, unknown>}>
<Paper elevation={0} sx={{ textAlign: 'center', p: 4 }}>
<Container {...{ maxWidth: 'md', sx: { minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6 } } as Record<string, unknown>}>
<Paper elevation={0} sx={{ textAlign: 'center', p: 4, mb: 4, maxWidth: 600 }}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'linear-gradient(135deg, var(--primary, #6200ee), var(--accent, #03dac6))', margin: '0 auto 16px' }} />
<Typography variant="h3" gutterBottom>
MetaBuilder
Build Anything, Visually
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Data-driven application platform. Configure routes via the admin panel or install a package with a default home page.
A 5-level meta-architecture for creating entire applications through visual workflows,
schema editors, and embedded scripting. No code required.
</Typography>
<Stack direction="row" spacing={2} sx={{ justifyContent: 'center' }}>
<Button variant="contained" color="primary" href="/app/ui/login">
<Stack direction="row" spacing={2} sx={{ justifyContent: 'center', flexWrap: 'wrap' }}>
<Button variant="contained" color="primary" href="/app">
Get Started
</Button>
<Button variant="outlined" href="/app/ui/login">
Sign In
</Button>
<Button variant="outlined" href="/app/dbal-daemon">
<Button variant="text" href="/dbal-daemon">
DBAL Status
</Button>
</Stack>
</Paper>
{/* Five levels of power (from Level1.tsx) */}
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
Five Levels of Power
</Typography>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, width: '100%', maxWidth: 700 }}>
{[
{ level: 1, name: 'Public Website', desc: 'Landing pages and public content', color: '#2196f3' },
{ level: 2, name: 'User Area', desc: 'Profiles, dashboards, comments', color: '#4caf50' },
{ level: 3, name: 'Admin Panel', desc: 'Data management and CRUD', color: '#ff9800' },
{ level: 4, name: 'God Builder', desc: 'Schemas, workflows, Lua', color: '#9c27b0' },
{ level: 5, name: 'Super God', desc: 'Multi-tenant control', color: '#ffc107' },
].map(item => (
<Paper key={item.level} sx={{ p: 2, borderTop: `3px solid ${item.color}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Avatar sx={{ width: 28, height: 28, bgcolor: item.color, fontSize: '0.8rem' }}>
{item.level}
</Avatar>
<Typography variant="subtitle2">{item.name}</Typography>
</div>
<Typography variant="caption" color="text.secondary">
{item.desc}
</Typography>
</Paper>
))}
</div>
</Container>
)
}

View File

@@ -0,0 +1,216 @@
/**
* AppBar Component
*
* Top navigation bar matching Qt6 App.qml header:
* - MetaBuilder branding + level badge
* - DBAL connection status indicator
* - Level navigation buttons (visible based on user level)
* - Theme toggle
* - Auth controls (login/logout)
*/
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import {
AppBar as MuiAppBar,
Typography,
Button,
Chip,
Avatar,
IconButton,
} from '@/fakemui'
import { getLevelLabel, getLevelColor } from '@/lib/packages/navigation'
export interface AppBarProps {
username: string | null
role: string
userLevel: number
isAuthenticated: boolean
onLogout: () => void
onToggleSidebar?: () => void
onToggleTheme?: () => void
themeMode?: 'light' | 'dark'
dbalConnected?: boolean
}
/** Level navigation mapping (mirrors Qt6 App.qml Repeater model) */
const levelNavItems = [
{ label: 'Public', level: 1, path: '/' },
{ label: 'User', level: 1, path: '/app/dashboard' },
{ label: 'Admin', level: 2, path: '/app/admin' },
{ label: 'God', level: 4, path: '/app/god-panel' },
{ label: 'Super God', level: 5, path: '/app/supergod' },
]
export function AppBarComponent({
username,
role,
userLevel,
isAuthenticated,
onLogout,
onToggleSidebar,
onToggleTheme,
themeMode = 'dark',
dbalConnected = false,
}: AppBarProps) {
const router = useRouter()
const [checkingDbal, setCheckingDbal] = useState(true)
const [dbalStatus, setDbalStatus] = useState(dbalConnected)
useEffect(() => {
const dbalUrl = process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080'
fetch(`${dbalUrl}/health`, { signal: AbortSignal.timeout(3000) })
.then(res => {
setDbalStatus(res.ok)
setCheckingDbal(false)
})
.catch(() => {
setDbalStatus(false)
setCheckingDbal(false)
})
}, [])
const levelColor = getLevelColor(userLevel)
return (
<MuiAppBar
position="sticky"
sx={{
height: 56,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
px: 2,
gap: 1.5,
zIndex: 1200,
}}
>
{/* Menu toggle (for mobile) */}
{isAuthenticated && onToggleSidebar != null && (
<IconButton
color="inherit"
onClick={onToggleSidebar}
sx={{ display: { sm: 'none' }, mr: 0.5 }}
aria-label="Toggle sidebar"
>
<span style={{ fontSize: '1.25rem' }}>&#9776;</span>
</IconButton>
)}
{/* Branding */}
<Link href="/" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, var(--primary, #6200ee), var(--accent, #03dac6))',
}}
/>
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
MetaBuilder
</Typography>
</Link>
{/* Level badge */}
{isAuthenticated && (
<Chip
label={`Level ${userLevel}`}
size="small"
sx={{
bgcolor: levelColor,
color: '#fff',
fontWeight: 600,
fontSize: '0.7rem',
height: 24,
}}
/>
)}
{/* DBAL connection status */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginLeft: 4 }}>
<div
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: checkingDbal ? '#ff9800' : dbalStatus ? '#4caf50' : '#f44336',
}}
/>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
DBAL
</Typography>
</div>
{/* Theme toggle */}
{onToggleTheme != null && (
<Button
variant="text"
size="small"
onClick={onToggleTheme}
sx={{ color: 'inherit', minWidth: 'auto', textTransform: 'none' }}
>
{themeMode === 'dark' ? 'Light' : 'Dark'}
</Button>
)}
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Level navigation (mirrors Qt6 Repeater) */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{levelNavItems
.filter(item => isAuthenticated ? item.level <= userLevel : item.level <= 1)
.map(item => (
<Button
key={item.path}
variant="text"
size="small"
onClick={() => { router.push(item.path) }}
sx={{
color: 'inherit',
textTransform: 'none',
fontSize: '0.8rem',
opacity: 0.85,
'&:hover': { opacity: 1 },
display: { xs: 'none', md: 'inline-flex' },
}}
>
{item.label}
</Button>
))}
</div>
{/* Spacer */}
<div style={{ width: 8 }} />
{/* Auth controls */}
{!isAuthenticated ? (
<Button
variant="contained"
size="small"
onClick={() => { router.push('/app/ui/login') }}
sx={{ textTransform: 'none' }}
>
Login
</Button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography variant="body2" sx={{ opacity: 0.85, display: { xs: 'none', sm: 'block' } }}>
{username} ({role})
</Typography>
<Button
variant="text"
size="small"
onClick={onLogout}
sx={{ color: 'inherit', textTransform: 'none' }}
>
Logout
</Button>
</div>
)}
</MuiAppBar>
)
}

View File

@@ -0,0 +1,137 @@
/**
* AppShell Layout Component
*
* The main authenticated layout matching the Qt6 App.qml structure:
* - AppBar (header with branding, level nav, auth controls)
* - DBAL offline banner
* - Sidebar (level-filtered nav) + Main content area
*
* This is the 5-level layout system ported from old/src/components/Level[1-5].tsx
* to a unified shell that adapts based on auth level.
*/
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { useTheme } from '@/app/providers'
import { AppBarComponent } from './AppBar'
import { Sidebar } from './Sidebar'
import { DbalBanner } from './DbalBanner'
import { getRoleLevel } from '@/lib/constants'
import type { PackageNavItem } from '@/lib/packages/navigation'
import { packageMetadataToNavItem } from '@/lib/packages/navigation'
export interface AppShellProps {
children: React.ReactNode
}
/** Fetch navigable packages from DBAL or fallback to empty */
async function fetchNavigablePackages(): Promise<PackageNavItem[]> {
try {
const dbalUrl = process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080'
const res = await fetch(`${dbalUrl}/system/core/package`, {
signal: AbortSignal.timeout(3000),
})
if (!res.ok) return []
const json = await res.json() as { data?: Array<Record<string, unknown>> }
if (!Array.isArray(json.data)) return []
return json.data
.filter((pkg): pkg is Record<string, unknown> => typeof pkg === 'object' && pkg !== null)
.map(pkg => packageMetadataToNavItem(pkg as {
packageId: string
name: string
navLabel?: string
icon?: string
level?: number
category?: string
showInNav?: boolean
}))
.filter(pkg => pkg.showInNav)
} catch {
return []
}
}
export function AppShell({ children }: AppShellProps) {
const auth = useAuthContext()
const { toggleTheme, resolvedMode } = useTheme()
const router = useRouter()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [dbalOffline, setDbalOffline] = useState(false)
const [packages, setPackages] = useState<PackageNavItem[]>([])
const userLevel = auth.user != null
? getRoleLevel(auth.user.role ?? 'user')
: 0
const username = auth.user?.username ?? auth.user?.name ?? 'User'
const role = auth.user?.role ?? 'public'
useEffect(() => {
// Check DBAL health
const dbalUrl = process.env.NEXT_PUBLIC_DBAL_API_URL ?? 'http://localhost:8080'
fetch(`${dbalUrl}/health`, { signal: AbortSignal.timeout(3000) })
.then(res => { setDbalOffline(!res.ok) })
.catch(() => { setDbalOffline(true) })
// Load navigable packages
void fetchNavigablePackages().then(setPackages)
}, [])
const handleLogout = useCallback(async () => {
await auth.logout()
router.push('/')
}, [auth, router])
const handleToggleSidebar = useCallback(() => {
setSidebarOpen(prev => !prev)
}, [])
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
{/* App Bar */}
<AppBarComponent
username={username}
role={role}
userLevel={userLevel}
isAuthenticated={auth.isAuthenticated}
onLogout={() => { void handleLogout() }}
onToggleSidebar={handleToggleSidebar}
onToggleTheme={toggleTheme}
themeMode={resolvedMode}
dbalConnected={!dbalOffline}
/>
{/* DBAL offline banner */}
<DbalBanner visible={dbalOffline} />
{/* Main area: Sidebar + Content */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sidebar (only shown when authenticated, mirrors Qt6 visible: loggedIn) */}
{auth.isAuthenticated && (
<Sidebar
userLevel={userLevel}
username={username}
role={role}
packages={packages}
open={sidebarOpen}
onClose={() => { setSidebarOpen(false) }}
/>
)}
{/* Main content */}
<main
style={{
flex: 1,
overflow: 'auto',
padding: '24px',
backgroundColor: 'var(--background, #fafafa)',
}}
>
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
/**
* DBAL Offline Banner
* Mirrors the Qt6 App.qml dbalBanner Rectangle
* Shown when DBAL daemon is unreachable
*/
'use client'
import { Typography } from '@/fakemui'
export interface DbalBannerProps {
visible: boolean
}
export function DbalBanner({ visible }: DbalBannerProps) {
if (!visible) return null
return (
<div
style={{
backgroundColor: '#e65100',
color: '#ffffff',
textAlign: 'center',
padding: '4px 0',
fontSize: '0.75rem',
zIndex: 1300,
}}
>
<Typography variant="caption" sx={{ color: '#fff' }}>
DBAL Offline &mdash; showing cached data
</Typography>
</div>
)
}

View File

@@ -0,0 +1,102 @@
/**
* LevelGate Component
*
* Protects content behind a minimum auth level.
* Mirrors the Qt6 pattern where nav items have `level` props
* and content is only visible when currentLevel >= requiredLevel.
*
* Usage:
* <LevelGate minLevel={4} levelName="God">
* <GodPanelContent />
* </LevelGate>
*/
'use client'
import { useAuthContext } from '@/app/_components/auth-provider/auth-provider-component'
import { getRoleLevel } from '@/lib/constants'
import { Typography, Button, Paper } from '@/fakemui'
import { useRouter } from 'next/navigation'
export interface LevelGateProps {
children: React.ReactNode
/** Minimum ROLE_LEVELS value required (0=public, 1=user, 2=mod, 3=admin, 4=god, 5=supergod) */
minLevel: number
/** Human-readable level name for the access denied message */
levelName?: string
/** If true, shows nothing instead of access denied */
silent?: boolean
}
export function LevelGate({
children,
minLevel,
levelName,
silent = false,
}: LevelGateProps) {
const auth = useAuthContext()
const router = useRouter()
const userLevel = auth.user != null
? getRoleLevel(auth.user.role ?? 'user')
: 0
// User has sufficient level
if (userLevel >= minLevel) {
return <>{children}</>
}
// Not authenticated at all
if (!auth.isAuthenticated) {
if (silent) return null
return (
<Paper
sx={{
p: 4,
textAlign: 'center',
maxWidth: 480,
mx: 'auto',
mt: 8,
}}
>
<Typography variant="h5" gutterBottom>
Authentication Required
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
You need to sign in to access this area.
</Typography>
<Button
variant="contained"
onClick={() => { router.push('/app/ui/login') }}
>
Sign In
</Button>
</Paper>
)
}
// Authenticated but insufficient level
if (silent) return null
return (
<Paper
sx={{
p: 4,
textAlign: 'center',
maxWidth: 480,
mx: 'auto',
mt: 8,
}}
>
<Typography variant="h5" gutterBottom>
Access Denied
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{levelName != null
? `${levelName} access (Level ${minLevel}) is required to view this page.`
: `Level ${minLevel} access is required to view this page.`}
</Typography>
<Typography variant="body2" color="text.secondary">
Your current level: {userLevel} ({auth.user?.role ?? 'unknown'})
</Typography>
</Paper>
)
}

View File

@@ -0,0 +1,262 @@
/**
* Sidebar Navigation Component
*
* Mirrors the Qt6 App.qml sidebar structure:
* - Static core nav items filtered by auth level
* - Dynamic package nav items from package metadata
* - Bottom-pinned settings item
*
* Data-driven: items come from sidebar-config.json
*/
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import {
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
Typography,
Avatar,
Chip,
Badge,
} from '@/fakemui'
import {
getSidebarItems,
getBottomSidebarItems,
getLevelLabel,
getLevelColor,
} from '@/lib/packages/navigation'
import type { PackageNavItem } from '@/lib/packages/navigation'
const SIDEBAR_WIDTH = 240
/** Map icon IDs to single-character fallbacks for the avatar */
const iconMap: Record<string, string> = {
dashboard: 'D',
person: 'P',
chat: 'C',
admin: 'A',
build: 'G',
crown: 'S',
settings: 'S',
package: 'K',
analytics: 'A',
forum: 'F',
gallery: 'G',
blog: 'B',
guestbook: 'G',
music: 'M',
marketplace: 'M',
}
function getIconChar(icon: string): string {
return iconMap[icon] ?? icon.charAt(0).toUpperCase()
}
export interface SidebarProps {
userLevel: number
username: string
role: string
packages?: PackageNavItem[]
open?: boolean
onClose?: () => void
}
export function Sidebar({
userLevel,
username,
role,
packages = [],
open = true,
onClose,
}: SidebarProps) {
const pathname = usePathname()
const staticItems = getSidebarItems(userLevel)
const bottomItems = getBottomSidebarItems(userLevel)
const navigablePackages = packages.filter(
pkg => pkg.showInNav && pkg.level <= userLevel
)
const levelLabel = getLevelLabel(userLevel)
const levelColor = getLevelColor(userLevel)
return (
<Drawer
variant="persistent"
open={open}
onClose={onClose}
sx={{
width: open ? SIDEBAR_WIDTH : 0,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: SIDEBAR_WIDTH,
position: 'relative',
height: '100%',
borderRight: '1px solid var(--border-color, #e0e0e0)',
backgroundColor: 'var(--surface-color, #fafafa)',
},
}}
>
{/* User info header */}
<div style={{ padding: '16px', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: levelColor, fontSize: '0.875rem' }}>
{username.charAt(0).toUpperCase()}
</Avatar>
<div style={{ overflow: 'hidden' }}>
<Typography variant="subtitle2" noWrap>{username}</Typography>
<Typography variant="caption" color="text.secondary" noWrap>{role}</Typography>
</div>
</div>
<Chip
label={`Level ${userLevel} - ${levelLabel}`}
size="small"
sx={{
backgroundColor: levelColor,
color: '#fff',
fontSize: '0.7rem',
height: '22px',
}}
/>
</div>
{/* Navigation label */}
<div style={{ padding: '12px 16px 4px' }}>
<Typography variant="overline" color="text.secondary" sx={{ fontSize: '0.65rem', letterSpacing: '0.1em' }}>
Navigation
</Typography>
</div>
{/* Static core nav items */}
<List dense sx={{ flex: 1, overflow: 'auto', py: 0 }}>
{staticItems.map(item => {
const isActive = pathname === item.path || pathname.startsWith(item.path + '/')
return (
<ListItem key={item.id} disablePadding>
<ListItemButton
component={Link}
href={item.path}
selected={isActive}
sx={{
py: 0.75,
px: 2,
borderRadius: '6px',
mx: 1,
my: 0.25,
'&.Mui-selected': {
backgroundColor: 'var(--primary-light, #e3f2fd)',
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: '0.75rem',
bgcolor: isActive ? levelColor : 'var(--surface-variant, #e0e0e0)',
color: isActive ? '#fff' : 'var(--text-secondary, #666)',
}}
>
{getIconChar(item.icon)}
</Avatar>
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
variant: 'body2',
fontWeight: isActive ? 600 : 400,
}}
/>
</ListItemButton>
</ListItem>
)
})}
{/* Dynamic package nav items */}
{navigablePackages.length > 0 && (
<>
<Divider sx={{ my: 1, mx: 2 }} />
<div style={{ padding: '4px 16px' }}>
<Typography variant="overline" color="text.secondary" sx={{ fontSize: '0.65rem', letterSpacing: '0.1em' }}>
Packages
</Typography>
</div>
{navigablePackages.map(pkg => {
const pkgPath = `/app/packages/${pkg.packageId}`
const isActive = pathname === pkgPath || pathname.startsWith(pkgPath + '/')
return (
<ListItem key={pkg.packageId} disablePadding>
<ListItemButton
component={Link}
href={pkgPath}
selected={isActive}
sx={{
py: 0.75,
px: 2,
borderRadius: '6px',
mx: 1,
my: 0.25,
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Avatar sx={{ width: 28, height: 28, fontSize: '0.75rem' }}>
{pkg.icon.charAt(0)}
</Avatar>
</ListItemIcon>
<ListItemText
primary={pkg.navLabel}
primaryTypographyProps={{ variant: 'body2' }}
/>
<Badge
badgeContent={`L${pkg.level}`}
color="default"
sx={{ '& .MuiBadge-badge': { fontSize: '0.6rem', right: -4 } }}
/>
</ListItemButton>
</ListItem>
)
})}
</>
)}
</List>
{/* Bottom items (settings, etc.) */}
<div>
<Divider />
<List dense sx={{ py: 0.5 }}>
{bottomItems.map(item => {
const isActive = pathname === item.path
return (
<ListItem key={item.id} disablePadding>
<ListItemButton
component={Link}
href={item.path}
selected={isActive}
sx={{ py: 0.75, px: 2, mx: 1, borderRadius: '6px' }}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Avatar sx={{ width: 28, height: 28, fontSize: '0.75rem' }}>
{getIconChar(item.icon)}
</Avatar>
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
</ListItem>
)
})}
</List>
</div>
</Drawer>
)
}

View File

@@ -0,0 +1,8 @@
/**
* Layout components barrel export
*/
export { AppShell } from './AppShell'
export { AppBarComponent } from './AppBar'
export { Sidebar } from './Sidebar'
export { DbalBanner } from './DbalBanner'
export { LevelGate } from './LevelGate'

View File

@@ -0,0 +1,99 @@
{
"tabs": [
{
"id": "overview",
"label": "Overview",
"icon": "dashboard",
"description": "System health and quick actions"
},
{
"id": "schemas",
"label": "Schemas",
"icon": "database",
"description": "Entity schema editor for DBAL"
},
{
"id": "workflows",
"label": "Workflows",
"icon": "workflow",
"description": "Visual DAG workflow builder"
},
{
"id": "packages",
"label": "Packages",
"icon": "package",
"description": "Install and manage platform packages"
},
{
"id": "pages",
"label": "Page Routes",
"icon": "pages",
"description": "Configure URL routes and page mappings"
},
{
"id": "components",
"label": "Components",
"icon": "components",
"description": "Component hierarchy and JSON renderer"
},
{
"id": "users",
"label": "Users",
"icon": "people",
"description": "User management and role assignment"
},
{
"id": "database",
"label": "Database",
"icon": "storage",
"description": "Database inspection and management"
},
{
"id": "credentials",
"label": "Credentials",
"icon": "key",
"description": "God-tier credential management"
},
{
"id": "theme",
"label": "Theme",
"icon": "palette",
"description": "Theme and styling configuration"
}
],
"tools": [
{
"id": "export-db",
"label": "Export Database",
"icon": "download",
"action": "exportDatabase"
},
{
"id": "import-db",
"label": "Import Database",
"icon": "upload",
"action": "importDatabase"
},
{
"id": "preview-l1",
"label": "Preview Level 1",
"icon": "visibility",
"action": "previewLevel",
"params": { "level": 1 }
},
{
"id": "preview-l2",
"label": "Preview Level 2",
"icon": "visibility",
"action": "previewLevel",
"params": { "level": 2 }
},
{
"id": "preview-l3",
"label": "Preview Level 3",
"icon": "visibility",
"action": "previewLevel",
"params": { "level": 3 }
}
]
}

View File

@@ -0,0 +1,65 @@
/**
* Navigation configuration loader
* Reads sidebar and god-panel configs from JSON files
*/
import sidebarConfigData from './sidebar-config.json'
import godPanelConfigData from './god-panel-config.json'
import type { SidebarConfig, GodPanelConfig, PackageNavItem, SidebarNavItem } from './types'
export type { SidebarConfig, GodPanelConfig, PackageNavItem, SidebarNavItem }
export type { GodPanelTab, GodPanelTool } from './types'
export const sidebarConfig: SidebarConfig = sidebarConfigData as SidebarConfig
export const godPanelConfig: GodPanelConfig = godPanelConfigData as GodPanelConfig
/**
* Get sidebar items filtered by user level
*/
export function getSidebarItems(userLevel: number): SidebarNavItem[] {
return sidebarConfig.staticItems.filter(item => item.level <= userLevel)
}
/**
* Get bottom sidebar items filtered by user level
*/
export function getBottomSidebarItems(userLevel: number): SidebarNavItem[] {
return sidebarConfig.bottomItems.filter(item => item.level <= userLevel)
}
/**
* Get the display label for a user level
*/
export function getLevelLabel(level: number): string {
return sidebarConfig.levelLabels[String(level)] ?? 'Unknown'
}
/**
* Get the display color for a user level
*/
export function getLevelColor(level: number): string {
return sidebarConfig.levelColors[String(level)] ?? '#666666'
}
/**
* Convert Qt6 package metadata to PackageNavItem for the sidebar
*/
export function packageMetadataToNavItem(metadata: {
packageId: string
name: string
navLabel?: string
icon?: string
level?: number
category?: string
showInNav?: boolean
}): PackageNavItem {
return {
packageId: metadata.packageId,
name: metadata.name,
navLabel: metadata.navLabel ?? metadata.name,
icon: metadata.icon ?? metadata.name.charAt(0).toUpperCase(),
level: metadata.level ?? 2,
category: metadata.category ?? 'general',
showInNav: metadata.showInNav ?? false,
}
}

View File

@@ -0,0 +1,28 @@
{
"staticItems": [
{ "id": "dashboard", "label": "Dashboard", "icon": "dashboard", "path": "/app/dashboard", "level": 1 },
{ "id": "profile", "label": "Profile", "icon": "person", "path": "/app/profile", "level": 1 },
{ "id": "comments", "label": "Comments", "icon": "chat", "path": "/app/comments", "level": 1 },
{ "id": "admin", "label": "Admin Panel", "icon": "admin", "path": "/app/admin", "level": 2 },
{ "id": "god-panel", "label": "God Panel", "icon": "build", "path": "/app/god-panel", "level": 4 },
{ "id": "supergod", "label": "Super God", "icon": "crown", "path": "/app/supergod", "level": 5 }
],
"bottomItems": [
{ "id": "settings", "label": "Settings", "icon": "settings", "path": "/app/settings", "level": 1 }
],
"levelLabels": {
"0": "Public",
"1": "User",
"2": "Moderator",
"3": "Admin",
"4": "God",
"5": "Super God"
},
"levelColors": {
"1": "#2196f3",
"2": "#4caf50",
"3": "#ff9800",
"4": "#9c27b0",
"5": "#ffc107"
}
}

View File

@@ -0,0 +1,49 @@
/**
* Navigation and sidebar type definitions
* Loaded from JSON config files - 95% data, 5% code
*/
export interface SidebarNavItem {
id: string
label: string
icon: string
path: string
level: number
}
export interface SidebarConfig {
staticItems: SidebarNavItem[]
bottomItems: SidebarNavItem[]
levelLabels: Record<string, string>
levelColors: Record<string, string>
}
export interface GodPanelTab {
id: string
label: string
icon: string
description: string
}
export interface GodPanelTool {
id: string
label: string
icon: string
action: string
params?: Record<string, unknown>
}
export interface GodPanelConfig {
tabs: GodPanelTab[]
tools: GodPanelTool[]
}
export interface PackageNavItem {
packageId: string
name: string
navLabel: string
icon: string
level: number
category: string
showInNav: boolean
}