mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
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:
205
frontends/nextjs/src/app/app/admin/page.tsx
Normal file
205
frontends/nextjs/src/app/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontends/nextjs/src/app/app/comments/page.tsx
Normal file
158
frontends/nextjs/src/app/app/comments/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
frontends/nextjs/src/app/app/dashboard/page.tsx
Normal file
164
frontends/nextjs/src/app/app/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
557
frontends/nextjs/src/app/app/god-panel/page.tsx
Normal file
557
frontends/nextjs/src/app/app/god-panel/page.tsx
Normal 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 → send_success callback → WfEngine → 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 → moderator → admin → god → 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 “{tab.label}” is not yet implemented.
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GodPanelPage() {
|
||||
return (
|
||||
<LevelGate minLevel={4} levelName="God">
|
||||
<GodPanelContent />
|
||||
</LevelGate>
|
||||
)
|
||||
}
|
||||
26
frontends/nextjs/src/app/app/layout.tsx
Normal file
26
frontends/nextjs/src/app/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
frontends/nextjs/src/app/app/packages/[packageId]/page.tsx
Normal file
150
frontends/nextjs/src/app/app/packages/[packageId]/page.tsx
Normal 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 “{packageId}” 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 “{metadata.name}”
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Package-specific UI components are loaded dynamically.
|
||||
In Qt6, this maps to PackageViewLoader with packageId: “{packageId}”.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
52
frontends/nextjs/src/app/app/page.tsx
Normal file
52
frontends/nextjs/src/app/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
frontends/nextjs/src/app/app/profile/page.tsx
Normal file
134
frontends/nextjs/src/app/app/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
frontends/nextjs/src/app/app/settings/page.tsx
Normal file
105
frontends/nextjs/src/app/app/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
394
frontends/nextjs/src/app/app/supergod/page.tsx
Normal file
394
frontends/nextjs/src/app/app/supergod/page.tsx
Normal 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' }}>♕</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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
216
frontends/nextjs/src/components/layout/AppBar.tsx
Normal file
216
frontends/nextjs/src/components/layout/AppBar.tsx
Normal 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' }}>☰</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>
|
||||
)
|
||||
}
|
||||
137
frontends/nextjs/src/components/layout/AppShell.tsx
Normal file
137
frontends/nextjs/src/components/layout/AppShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontends/nextjs/src/components/layout/DbalBanner.tsx
Normal file
33
frontends/nextjs/src/components/layout/DbalBanner.tsx
Normal 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 — showing cached data
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
frontends/nextjs/src/components/layout/LevelGate.tsx
Normal file
102
frontends/nextjs/src/components/layout/LevelGate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
262
frontends/nextjs/src/components/layout/Sidebar.tsx
Normal file
262
frontends/nextjs/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
frontends/nextjs/src/components/layout/index.ts
Normal file
8
frontends/nextjs/src/components/layout/index.ts
Normal 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'
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
65
frontends/nextjs/src/lib/packages/navigation/index.ts
Normal file
65
frontends/nextjs/src/lib/packages/navigation/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
49
frontends/nextjs/src/lib/packages/navigation/types.ts
Normal file
49
frontends/nextjs/src/lib/packages/navigation/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user