Compare commits

...

12 Commits

Author SHA1 Message Date
4ab7aac63e Merge branch 'main' into codex/add-user-management-components 2025-12-27 18:48:59 +00:00
1f7c2e637e Merge pull request #257 from johndoe6345789/codex/create-fields-and-actions-components
Refactor component dialog fields and hierarchy tree
2025-12-27 18:48:35 +00:00
9c354fdac5 Merge branch 'main' into codex/create-fields-and-actions-components 2025-12-27 18:48:26 +00:00
f57b41f86d refactor: extract dialog fields and hierarchy tree 2025-12-27 18:48:15 +00:00
1e9a6271ea feat: add user management subcomponents 2025-12-27 18:47:43 +00:00
7989c700b9 Merge pull request #254 from johndoe6345789/codex/create-shared-powertransfer-tabs-component
Refactor power transfer tab layout
2025-12-27 18:47:21 +00:00
02e7188b20 Merge branch 'main' into codex/create-shared-powertransfer-tabs-component 2025-12-27 18:47:13 +00:00
1523cf735c refactor: extract power transfer sections 2025-12-27 18:47:02 +00:00
adedf5f70c Merge pull request #253 from johndoe6345789/codex/create-level4/tabs/config.ts-and-tabcontent.tsx
refactor: modularize level4 tabs
2025-12-27 18:46:26 +00:00
c069bd0540 Merge branch 'main' into codex/create-level4/tabs/config.ts-and-tabcontent.tsx 2025-12-27 18:46:18 +00:00
871b84ebf4 refactor: modularize level4 tabs 2025-12-27 18:46:06 +00:00
db8c01de1b Merge pull request #251 from johndoe6345789/codex/create-section-components-for-levels
Refactor level pages to share section components
2025-12-27 18:45:35 +00:00
14 changed files with 1184 additions and 545 deletions

View File

@@ -1,23 +1,7 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Database as DatabaseIcon, Lightning, Code, BookOpen, HardDrives, MapTrifold, Tree, Users, Gear, Palette, ListDashes, Sparkle, Package, SquaresFour, Warning } from '@phosphor-icons/react'
import { SchemaEditorLevel4 } from '@/components/SchemaEditorLevel4'
import { WorkflowEditor } from '@/components/WorkflowEditor'
import { LuaEditor } from '@/components/editors/lua/LuaEditor'
import { LuaBlocksEditor } from '@/components/editors/lua/LuaBlocksEditor'
import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary'
import { DatabaseManager } from '@/components/managers/database/DatabaseManager'
import { PageRoutesManager } from '@/components/managers/PageRoutesManager'
import { ComponentHierarchyEditor } from '@/components/ComponentHierarchyEditor'
import { UserManagement } from '@/components/UserManagement'
import { GodCredentialsSettings } from '@/components/GodCredentialsSettings'
import { CssClassManager } from '@/components/CssClassManager'
import { DropdownConfigManager } from '@/components/DropdownConfigManager'
import { QuickGuide } from '@/components/QuickGuide'
import { PackageManager } from '@/components/PackageManager'
import { ThemeEditor } from '@/components/ThemeEditor'
import { SMTPConfigEditor } from '@/components/SMTPConfigEditor'
import { ErrorLogsTab } from '@/components/level5/tabs/error-logs/ErrorLogsTab'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui'
import type { AppConfiguration, User } from '@/lib/level-types'
import { level4TabsConfig } from './tabs/config'
import { TabContent } from './tabs/TabContent'
interface Level4TabsProps {
appConfig: AppConfiguration
@@ -36,153 +20,31 @@ export function Level4Tabs({
onWorkflowsChange,
onLuaScriptsChange,
}: Level4TabsProps) {
const visibleTabs = level4TabsConfig.filter((tab) => (tab.nerdOnly ? nerdMode : true))
return (
<Tabs defaultValue="guide" className="space-y-6">
<TabsList className={nerdMode ? "grid w-full grid-cols-4 lg:grid-cols-14 max-w-full" : "grid w-full grid-cols-3 lg:grid-cols-7 max-w-full"}>
<TabsTrigger value="guide">
<Sparkle className="mr-2" size={16} />
Guide
</TabsTrigger>
<TabsTrigger value="packages">
<Package className="mr-2" size={16} />
Packages
</TabsTrigger>
<TabsTrigger value="pages">
<MapTrifold className="mr-2" size={16} />
Page Routes
</TabsTrigger>
<TabsTrigger value="hierarchy">
<Tree className="mr-2" size={16} />
Components
</TabsTrigger>
<TabsTrigger value="users">
<Users className="mr-2" size={16} />
Users
</TabsTrigger>
<TabsTrigger value="schemas">
<DatabaseIcon className="mr-2" size={16} />
Schemas
</TabsTrigger>
{nerdMode && (
<>
<TabsTrigger value="workflows">
<Lightning className="mr-2" size={16} />
Workflows
</TabsTrigger>
<TabsTrigger value="lua">
<Code className="mr-2" size={16} />
Lua Scripts
</TabsTrigger>
<TabsTrigger value="blocks">
<SquaresFour className="mr-2" size={16} />
Lua Blocks
</TabsTrigger>
<TabsTrigger value="snippets">
<BookOpen className="mr-2" size={16} />
Snippets
</TabsTrigger>
<TabsTrigger value="css">
<Palette className="mr-2" size={16} />
CSS Classes
</TabsTrigger>
<TabsTrigger value="dropdowns">
<ListDashes className="mr-2" size={16} />
Dropdowns
</TabsTrigger>
<TabsTrigger value="database">
<HardDrives className="mr-2" size={16} />
Database
</TabsTrigger>
</>
)}
<TabsTrigger value="settings">
<Gear className="mr-2" size={16} />
Settings
</TabsTrigger>
<TabsTrigger value="errorlogs">
<Warning className="mr-2" size={16} />
Error Logs
</TabsTrigger>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="mr-2" size={16} />
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="guide" className="space-y-6">
<QuickGuide />
</TabsContent>
<TabsContent value="packages" className="space-y-6">
<PackageManager />
</TabsContent>
<TabsContent value="pages" className="space-y-6">
<PageRoutesManager />
</TabsContent>
<TabsContent value="hierarchy" className="space-y-6">
<ComponentHierarchyEditor nerdMode={nerdMode} />
</TabsContent>
<TabsContent value="users" className="space-y-6">
<UserManagement />
</TabsContent>
<TabsContent value="schemas" className="space-y-6">
<SchemaEditorLevel4
schemas={appConfig.schemas}
{level4TabsConfig.map((tab) => (
<TabContent
key={tab.value}
tab={tab}
appConfig={appConfig}
user={user}
nerdMode={nerdMode}
onSchemasChange={onSchemasChange}
onWorkflowsChange={onWorkflowsChange}
onLuaScriptsChange={onLuaScriptsChange}
/>
</TabsContent>
{nerdMode && (
<>
<TabsContent value="workflows" className="space-y-6">
<WorkflowEditor
workflows={appConfig.workflows}
onWorkflowsChange={onWorkflowsChange}
scripts={appConfig.luaScripts}
/>
</TabsContent>
<TabsContent value="lua" className="space-y-6">
<LuaEditor
scripts={appConfig.luaScripts}
onScriptsChange={onLuaScriptsChange}
/>
</TabsContent>
<TabsContent value="blocks" className="space-y-6">
<LuaBlocksEditor
scripts={appConfig.luaScripts}
onScriptsChange={onLuaScriptsChange}
/>
</TabsContent>
<TabsContent value="snippets" className="space-y-6">
<LuaSnippetLibrary />
</TabsContent>
<TabsContent value="css" className="space-y-6">
<CssClassManager />
</TabsContent>
<TabsContent value="dropdowns" className="space-y-6">
<DropdownConfigManager />
</TabsContent>
<TabsContent value="database" className="space-y-6">
<DatabaseManager />
</TabsContent>
</>
)}
<TabsContent value="settings" className="space-y-6">
<GodCredentialsSettings />
<ThemeEditor />
<SMTPConfigEditor />
</TabsContent>
<TabsContent value="errorlogs" className="space-y-6">
<ErrorLogsTab user={user} />
</TabsContent>
))}
</Tabs>
)
}

View File

@@ -0,0 +1,153 @@
import { TabsContent } from '@/components/ui'
import { SchemaEditorLevel4 } from '@/components/SchemaEditorLevel4'
import { WorkflowEditor } from '@/components/WorkflowEditor'
import { LuaEditor } from '@/components/editors/lua/LuaEditor'
import { LuaBlocksEditor } from '@/components/editors/lua/LuaBlocksEditor'
import { LuaSnippetLibrary } from '@/components/editors/lua/LuaSnippetLibrary'
import { DatabaseManager } from '@/components/managers/database/DatabaseManager'
import { PageRoutesManager } from '@/components/managers/PageRoutesManager'
import { ComponentHierarchyEditor } from '@/components/ComponentHierarchyEditor'
import { UserManagement } from '@/components/UserManagement'
import { GodCredentialsSettings } from '@/components/GodCredentialsSettings'
import { CssClassManager } from '@/components/CssClassManager'
import { DropdownConfigManager } from '@/components/DropdownConfigManager'
import { QuickGuide } from '@/components/QuickGuide'
import { PackageManager } from '@/components/PackageManager'
import { ThemeEditor } from '@/components/ThemeEditor'
import { SMTPConfigEditor } from '@/components/SMTPConfigEditor'
import { ErrorLogsTab } from '@/components/level5/tabs/error-logs/ErrorLogsTab'
import type { AppConfiguration, User } from '@/lib/level-types'
import type { Level4TabConfig } from './config'
interface Level4TabContentProps {
tab: Level4TabConfig
appConfig: AppConfiguration
user: User
nerdMode: boolean
onSchemasChange: (schemas: any[]) => Promise<void>
onWorkflowsChange: (workflows: any[]) => Promise<void>
onLuaScriptsChange: (scripts: any[]) => Promise<void>
}
export function TabContent({
tab,
appConfig,
user,
nerdMode,
onSchemasChange,
onWorkflowsChange,
onLuaScriptsChange,
}: Level4TabContentProps) {
if (tab.nerdOnly && !nerdMode) return null
switch (tab.value) {
case 'guide':
return (
<TabsContent value={tab.value} className="space-y-6">
<QuickGuide />
</TabsContent>
)
case 'packages':
return (
<TabsContent value={tab.value} className="space-y-6">
<PackageManager />
</TabsContent>
)
case 'pages':
return (
<TabsContent value={tab.value} className="space-y-6">
<PageRoutesManager />
</TabsContent>
)
case 'hierarchy':
return (
<TabsContent value={tab.value} className="space-y-6">
<ComponentHierarchyEditor nerdMode={nerdMode} />
</TabsContent>
)
case 'users':
return (
<TabsContent value={tab.value} className="space-y-6">
<UserManagement />
</TabsContent>
)
case 'schemas':
return (
<TabsContent value={tab.value} className="space-y-6">
<SchemaEditorLevel4
schemas={appConfig.schemas}
onSchemasChange={onSchemasChange}
/>
</TabsContent>
)
case 'workflows':
return (
<TabsContent value={tab.value} className="space-y-6">
<WorkflowEditor
workflows={appConfig.workflows}
onWorkflowsChange={onWorkflowsChange}
scripts={appConfig.luaScripts}
/>
</TabsContent>
)
case 'lua':
return (
<TabsContent value={tab.value} className="space-y-6">
<LuaEditor
scripts={appConfig.luaScripts}
onScriptsChange={onLuaScriptsChange}
/>
</TabsContent>
)
case 'blocks':
return (
<TabsContent value={tab.value} className="space-y-6">
<LuaBlocksEditor
scripts={appConfig.luaScripts}
onScriptsChange={onLuaScriptsChange}
/>
</TabsContent>
)
case 'snippets':
return (
<TabsContent value={tab.value} className="space-y-6">
<LuaSnippetLibrary />
</TabsContent>
)
case 'css':
return (
<TabsContent value={tab.value} className="space-y-6">
<CssClassManager />
</TabsContent>
)
case 'dropdowns':
return (
<TabsContent value={tab.value} className="space-y-6">
<DropdownConfigManager />
</TabsContent>
)
case 'database':
return (
<TabsContent value={tab.value} className="space-y-6">
<DatabaseManager />
</TabsContent>
)
case 'settings':
return (
<TabsContent value={tab.value} className="space-y-6">
<GodCredentialsSettings />
<ThemeEditor />
<SMTPConfigEditor />
</TabsContent>
)
case 'errorlogs':
return (
<TabsContent value={tab.value} className="space-y-6">
<ErrorLogsTab user={user} />
</TabsContent>
)
default:
return null
}
}

View File

@@ -0,0 +1,59 @@
import {
BookOpen,
Code,
Database as DatabaseIcon,
Gear,
HardDrives,
Lightning,
ListDashes,
MapTrifold,
Package,
Palette,
Sparkle,
SquaresFour,
Tree,
Users,
Warning,
} from '@phosphor-icons/react'
export type Level4TabValue =
| 'guide'
| 'packages'
| 'pages'
| 'hierarchy'
| 'users'
| 'schemas'
| 'workflows'
| 'lua'
| 'blocks'
| 'snippets'
| 'css'
| 'dropdowns'
| 'database'
| 'settings'
| 'errorlogs'
export interface Level4TabConfig {
value: Level4TabValue
label: string
icon: typeof DatabaseIcon
nerdOnly?: boolean
}
export const level4TabsConfig: Level4TabConfig[] = [
{ value: 'guide', label: 'Guide', icon: Sparkle },
{ value: 'packages', label: 'Packages', icon: Package },
{ value: 'pages', label: 'Page Routes', icon: MapTrifold },
{ value: 'hierarchy', label: 'Components', icon: Tree },
{ value: 'users', label: 'Users', icon: Users },
{ value: 'schemas', label: 'Schemas', icon: DatabaseIcon },
{ value: 'workflows', label: 'Workflows', icon: Lightning, nerdOnly: true },
{ value: 'lua', label: 'Lua Scripts', icon: Code, nerdOnly: true },
{ value: 'blocks', label: 'Lua Blocks', icon: SquaresFour, nerdOnly: true },
{ value: 'snippets', label: 'Snippets', icon: BookOpen, nerdOnly: true },
{ value: 'css', label: 'CSS Classes', icon: Palette, nerdOnly: true },
{ value: 'dropdowns', label: 'Dropdowns', icon: ListDashes, nerdOnly: true },
{ value: 'database', label: 'Database', icon: HardDrives, nerdOnly: true },
{ value: 'settings', label: 'Settings', icon: Gear },
{ value: 'errorlogs', label: 'Error Logs', icon: Warning },
]

View File

@@ -1,15 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Badge } from '@/components/ui'
import { Separator } from '@/components/ui'
import { Alert, AlertDescription } from '@/components/ui'
import { Crown, ArrowsLeftRight } from '@phosphor-icons/react'
import { ArrowsLeftRight } from '@phosphor-icons/react'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@/components/ui'
import type { PowerTransferRequest, User } from '@/lib/level-types'
import { fetchPowerTransferRequests } from '@/lib/api/power-transfers'
import { CriticalActionNotice, TransferHistory, UserSelectionList } from './sections'
interface PowerTransferTabProps {
currentUser: User
@@ -18,12 +14,6 @@ interface PowerTransferTabProps {
refreshSignal?: number
}
const STATUS_VARIANTS: Record<PowerTransferRequest['status'], 'default' | 'secondary' | 'destructive'> = {
accepted: 'default',
pending: 'secondary',
rejected: 'destructive',
}
export function PowerTransferTab({
currentUser,
allUsers,
@@ -35,9 +25,7 @@ export function PowerTransferTab({
const [isLoadingRequests, setIsLoadingRequests] = useState(true)
const [requestError, setRequestError] = useState<string | null>(null)
const highlightedUsers = allUsers.filter(
(u) => u.id !== currentUser.id && u.role !== 'supergod'
)
const highlightedUsers = allUsers.filter((u) => u.id !== currentUser.id && u.role !== 'supergod')
useEffect(() => {
let isActive = true
@@ -69,22 +57,6 @@ export function PowerTransferTab({
}
}, [refreshSignal])
const sortedRequests = [...requests].sort((a, b) => b.createdAt - a.createdAt)
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
const formatExpiry = (expiresAt: number) => {
const diff = expiresAt - Date.now()
if (diff <= 0) {
return 'Expired'
}
const minutes = Math.floor(diff / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
return `${minutes}m ${seconds}s remaining`
}
const getUserLabel = (userId: string) => {
const user = allUsers.find((u) => u.id === userId)
return user ? user.username : userId
@@ -100,97 +72,22 @@ export function PowerTransferTab({
</CardHeader>
<CardContent className="space-y-6">
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<div className="flex gap-3">
<Crown className="w-6 h-6 text-amber-400 flex-shrink-0" weight="fill" />
<div>
<h4 className="font-semibold text-amber-200 mb-1">Critical Action</h4>
<p className="text-sm text-amber-300/80">
This action cannot be undone. Only one Super God can exist at a time. After transfer,
you will have God-level access only.
</p>
</div>
</div>
</div>
<CriticalActionNotice />
<Separator className="bg-white/10" />
<div className="space-y-4">
<h4 className="font-semibold text-white">Select User to Transfer Power To:</h4>
<ScrollArea className="h-[300px]">
<div className="space-y-2">
{highlightedUsers.map((user) => (
<Card
key={user.id}
className={`cursor-pointer transition-all ${
selectedUserId === user.id
? 'bg-purple-600/30 border-purple-500'
: 'bg-white/5 border-white/10 hover:bg-white/10'
}`}
onClick={() => setSelectedUserId(user.id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white">{user.username}</p>
<p className="text-sm text-gray-400">{user.email}</p>
</div>
<Badge variant="outline" className="text-gray-300 border-gray-500/50">
{user.role}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
</div>
<UserSelectionList
users={highlightedUsers}
selectedUserId={selectedUserId}
onSelect={setSelectedUserId}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-white">Recent transfers</h4>
{isLoadingRequests && (
<span className="text-xs text-muted-foreground">Refreshing...</span>
)}
</div>
{requestError && (
<Alert variant="destructive">
<AlertDescription>{requestError}</AlertDescription>
</Alert>
)}
<ScrollArea className="h-[260px]">
<div className="space-y-3 p-2">
{sortedRequests.length === 0 && !isLoadingRequests ? (
<p className="text-sm text-muted-foreground">No transfer history available.</p>
) : (
sortedRequests.map((request) => (
<Card key={request.id} className="bg-white/5 border-white/10">
<CardHeader className="flex items-center justify-between space-y-0">
<div>
<CardTitle className="text-base text-white">
Transfer to {getUserLabel(request.toUserId)}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Requested by {getUserLabel(request.fromUserId)}
</CardDescription>
</div>
<Badge variant={STATUS_VARIANTS[request.status]}>
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
</Badge>
</CardHeader>
<CardContent className="text-xs text-muted-foreground space-y-1">
<p>Created: {formatDate(request.createdAt)}</p>
<p>Expires: {formatDate(request.expiresAt)}</p>
<p>{formatExpiry(request.expiresAt)}</p>
</CardContent>
</Card>
))
)}
</div>
</ScrollArea>
</div>
<TransferHistory
requests={requests}
getUserLabel={getUserLabel}
isLoading={isLoadingRequests}
requestError={requestError}
/>
<Button
onClick={() => onInitiateTransfer(selectedUserId)}

View File

@@ -0,0 +1,148 @@
'use client'
import { Crown } from '@phosphor-icons/react'
import {
Alert,
AlertDescription,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from '@/components/ui'
import type { PowerTransferRequest, User } from '@/lib/level-types'
const STATUS_VARIANTS: Record<PowerTransferRequest['status'], 'default' | 'secondary' | 'destructive'> = {
accepted: 'default',
pending: 'secondary',
rejected: 'destructive',
}
export const formatDate = (timestamp: number) => new Date(timestamp).toLocaleString()
export const formatExpiry = (expiresAt: number) => {
const diff = expiresAt - Date.now()
if (diff <= 0) {
return 'Expired'
}
const minutes = Math.floor(diff / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
return `${minutes}m ${seconds}s remaining`
}
export function CriticalActionNotice() {
return (
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<div className="flex gap-3">
<Crown className="w-6 h-6 text-amber-400 flex-shrink-0" weight="fill" />
<div>
<h4 className="font-semibold text-amber-200 mb-1">Critical Action</h4>
<p className="text-sm text-amber-300/80">
This action cannot be undone. Only one Super God can exist at a time. After transfer, you
will have God-level access only.
</p>
</div>
</div>
</div>
)
}
interface UserSelectionListProps {
users: User[]
selectedUserId: string
onSelect: (userId: string) => void
}
export function UserSelectionList({ users, selectedUserId, onSelect }: UserSelectionListProps) {
return (
<div className="space-y-4">
<h4 className="font-semibold text-white">Select User to Transfer Power To:</h4>
<ScrollArea className="h-[300px]">
<div className="space-y-2">
{users.map((user) => (
<Card
key={user.id}
className={`cursor-pointer transition-all ${
selectedUserId === user.id
? 'bg-purple-600/30 border-purple-500'
: 'bg-white/5 border-white/10 hover:bg-white/10'
}`}
onClick={() => onSelect(user.id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white">{user.username}</p>
<p className="text-sm text-gray-400">{user.email}</p>
</div>
<Badge variant="outline" className="text-gray-300 border-gray-500/50">
{user.role}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
</div>
)
}
interface TransferHistoryProps {
requests: PowerTransferRequest[]
getUserLabel: (userId: string) => string
isLoading: boolean
requestError: string | null
}
export function TransferHistory({ requests, getUserLabel, isLoading, requestError }: TransferHistoryProps) {
const sortedRequests = [...requests].sort((a, b) => b.createdAt - a.createdAt)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-white">Recent transfers</h4>
{isLoading && <span className="text-xs text-muted-foreground">Refreshing...</span>}
</div>
{requestError && (
<Alert variant="destructive">
<AlertDescription>{requestError}</AlertDescription>
</Alert>
)}
<ScrollArea className="h-[260px]">
<div className="space-y-3 p-2">
{sortedRequests.length === 0 && !isLoading ? (
<p className="text-sm text-muted-foreground">No transfer history available.</p>
) : (
sortedRequests.map((request) => (
<Card key={request.id} className="bg-white/5 border-white/10">
<CardHeader className="flex items-center justify-between space-y-0">
<div>
<CardTitle className="text-base text-white">
Transfer to {getUserLabel(request.toUserId)}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Requested by {getUserLabel(request.fromUserId)}
</CardDescription>
</div>
<Badge variant={STATUS_VARIANTS[request.status]}>
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
</Badge>
</CardHeader>
<CardContent className="text-xs text-muted-foreground space-y-1">
<p>Created: {formatDate(request.createdAt)}</p>
<p>Expires: {formatDate(request.expiresAt)}</p>
<p>{formatExpiry(request.expiresAt)}</p>
</CardContent>
</Card>
))
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -1,24 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Textarea } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
import { Database, ComponentNode, ComponentConfig } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
import type { PropDefinition } from '@/lib/builder-types'
/** Select option type for property schema options */
interface SelectOption {
value: string
label: string
}
import { ComponentConfigActions } from './ComponentConfigDialog/Actions'
import { ComponentConfigFields } from './ComponentConfigDialog/Fields'
interface ComponentConfigDialogProps {
node: ComponentNode
@@ -74,65 +60,6 @@ export function ComponentConfigDialog({ node, isOpen, onClose, onSave, nerdMode
const componentDef = componentCatalog.find(c => c.type === node.type)
const renderPropEditor = (propSchema: PropDefinition) => {
const value = props[propSchema.name] ?? propSchema.defaultValue
switch (propSchema.type) {
case 'string':
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
placeholder={String(propSchema.defaultValue || '')}
/>
)
case 'number':
return (
<Input
type="number"
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: Number(e.target.value) })}
/>
)
case 'boolean':
return (
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) => setProps({ ...props, [propSchema.name]: checked })}
/>
)
case 'select':
return (
<Select
value={String(value || propSchema.defaultValue || '')}
onValueChange={(val) => setProps({ ...props, [propSchema.name]: val })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{propSchema.options?.map((opt: SelectOption) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
/>
)
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh]">
@@ -143,147 +70,18 @@ export function ComponentConfigDialog({ node, isOpen, onClose, onSave, nerdMode
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="props" className="flex-1">
<TabsList className={nerdMode ? "grid w-full grid-cols-3" : "grid w-full grid-cols-2"}>
<TabsTrigger value="props">Properties</TabsTrigger>
<TabsTrigger value="styles">Styles</TabsTrigger>
{nerdMode && <TabsTrigger value="events">Events</TabsTrigger>}
</TabsList>
<ComponentConfigFields
componentDef={componentDef}
props={props}
setProps={setProps}
styles={styles}
setStyles={setStyles}
events={events}
setEvents={setEvents}
nerdMode={nerdMode}
/>
<ScrollArea className="h-[500px] mt-4">
<TabsContent value="props" className="space-y-4 px-1">
{componentDef?.propSchema && componentDef.propSchema.length > 0 ? (
componentDef.propSchema.map((propSchema) => (
<div key={propSchema.name} className="space-y-2">
<Label htmlFor={propSchema.name}>{propSchema.label}</Label>
{renderPropEditor(propSchema)}
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No configurable properties for this component</p>
</div>
)}
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Properties (JSON)</CardTitle>
<CardDescription className="text-xs">
Add additional props as JSON
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(props, null, 2)}
onChange={(e) => {
try {
setProps(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="styles" className="space-y-4 px-1">
<div className="space-y-2">
<Label htmlFor="className">Tailwind Classes</Label>
<Input
id="className"
value={String(props.className || '')}
onChange={(e) => setProps({ ...props, className: e.target.value })}
placeholder="p-4 bg-white rounded-lg"
/>
</div>
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Styles (CSS-in-JS)</CardTitle>
<CardDescription className="text-xs">
Define inline styles as JSON object
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(styles, null, 2)}
onChange={(e) => {
try {
setStyles(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={12}
placeholder='{\n "backgroundColor": "#fff",\n "padding": "16px"\n}'
/>
</CardContent>
</Card>
)}
</TabsContent>
{nerdMode && (
<TabsContent value="events" className="space-y-4 px-1">
<Card>
<CardHeader>
<CardTitle className="text-sm">Event Handlers</CardTitle>
<CardDescription className="text-xs">
Map events to Lua script IDs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur'].map((eventName) => (
<div key={eventName} className="space-y-2">
<Label htmlFor={eventName}>{eventName}</Label>
<Input
id={eventName}
value={events[eventName] || ''}
onChange={(e) => setEvents({ ...events, [eventName]: e.target.value })}
placeholder="lua_script_id"
/>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Custom Events (JSON)</CardTitle>
<CardDescription className="text-xs">
Define additional event handlers
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(events, null, 2)}
onChange={(e) => {
try {
setEvents(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
</TabsContent>
)}
</ScrollArea>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => void handleSave()}>Save Configuration</Button>
</DialogFooter>
<ComponentConfigActions onClose={onClose} onSave={handleSave} />
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,20 @@
import { Button } from '@/components/ui'
import { DialogFooter } from '@/components/ui'
interface ComponentConfigActionsProps {
onClose: () => void
onSave: () => Promise<void> | void
}
export function ComponentConfigActions({ onClose, onSave }: ComponentConfigActionsProps) {
return (
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={() => void onSave()}>
Save Configuration
</Button>
</DialogFooter>
)
}

View File

@@ -0,0 +1,238 @@
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Textarea } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Switch } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import type { ComponentDefinition, PropDefinition } from '@/lib/components/types'
interface SelectOption {
value: string
label: string
}
interface ComponentConfigFieldsProps {
componentDef?: ComponentDefinition
props: Record<string, unknown>
setProps: (value: Record<string, unknown>) => void
styles: Record<string, unknown>
setStyles: (value: Record<string, unknown>) => void
events: Record<string, string>
setEvents: (value: Record<string, string>) => void
nerdMode: boolean
}
function renderPropEditor(
propSchema: PropDefinition,
props: Record<string, unknown>,
setProps: (value: Record<string, unknown>) => void
) {
const value = props[propSchema.name] ?? propSchema.defaultValue
switch (propSchema.type) {
case 'string':
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
placeholder={String(propSchema.defaultValue || '')}
/>
)
case 'number':
return (
<Input
type="number"
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: Number(e.target.value) })}
/>
)
case 'boolean':
return (
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) => setProps({ ...props, [propSchema.name]: checked })}
/>
)
case 'select':
return (
<Select
value={String(value || propSchema.defaultValue || '')}
onValueChange={(val) => setProps({ ...props, [propSchema.name]: val })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{propSchema.options?.map((opt: SelectOption) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return (
<Input
value={String(value || '')}
onChange={(e) => setProps({ ...props, [propSchema.name]: e.target.value })}
/>
)
}
}
export function ComponentConfigFields({
componentDef,
props,
setProps,
styles,
setStyles,
events,
setEvents,
nerdMode,
}: ComponentConfigFieldsProps) {
return (
<Tabs defaultValue="props" className="flex-1">
<TabsList className={nerdMode ? "grid w-full grid-cols-3" : "grid w-full grid-cols-2"}>
<TabsTrigger value="props">Properties</TabsTrigger>
<TabsTrigger value="styles">Styles</TabsTrigger>
{nerdMode && <TabsTrigger value="events">Events</TabsTrigger>}
</TabsList>
<ScrollArea className="h-[500px] mt-4">
<TabsContent value="props" className="space-y-4 px-1">
{componentDef?.propSchema && componentDef.propSchema.length > 0 ? (
componentDef.propSchema.map((propSchema) => (
<div key={propSchema.name} className="space-y-2">
<Label htmlFor={propSchema.name}>{propSchema.label}</Label>
{renderPropEditor(propSchema, props, setProps)}
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No configurable properties for this component</p>
</div>
)}
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Properties (JSON)</CardTitle>
<CardDescription className="text-xs">
Add additional props as JSON
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(props, null, 2)}
onChange={(e) => {
try {
setProps(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="styles" className="space-y-4 px-1">
<div className="space-y-2">
<Label htmlFor="className">Tailwind Classes</Label>
<Input
id="className"
value={String(props.className || '')}
onChange={(e) => setProps({ ...props, className: e.target.value })}
placeholder="p-4 bg-white rounded-lg"
/>
</div>
{nerdMode && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-sm">Custom Styles (CSS-in-JS)</CardTitle>
<CardDescription className="text-xs">
Define inline styles as JSON object
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(styles, null, 2)}
onChange={(e) => {
try {
setStyles(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={12}
placeholder={'{\n "backgroundColor": "#fff",\n "padding": "16px"\n}'}
/>
</CardContent>
</Card>
)}
</TabsContent>
{nerdMode && (
<TabsContent value="events" className="space-y-4 px-1">
<Card>
<CardHeader>
<CardTitle className="text-sm">Event Handlers</CardTitle>
<CardDescription className="text-xs">
Map events to Lua script IDs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur'].map((eventName) => (
<div key={eventName} className="space-y-2">
<Label htmlFor={eventName}>{eventName}</Label>
<Input
id={eventName}
value={events[eventName] || ''}
onChange={(e) => setEvents({ ...events, [eventName]: e.target.value })}
placeholder="lua_script_id"
/>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Custom Events (JSON)</CardTitle>
<CardDescription className="text-xs">
Define additional event handlers
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={JSON.stringify(events, null, 2)}
onChange={(e) => {
try {
setEvents(JSON.parse(e.target.value))
} catch {
// Ignore invalid JSON during typing
}
}}
className="font-mono text-xs"
rows={6}
/>
</CardContent>
</Card>
</TabsContent>
)}
</ScrollArea>
</Tabs>
)
}

View File

@@ -6,7 +6,6 @@ import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
ArrowsOutCardinal,
Cursor,
Plus,
Tree,
} from '@phosphor-icons/react'
@@ -14,9 +13,10 @@ import { Database, type ComponentNode } from '@/lib/database'
import { componentCatalog } from '@/lib/components/component-catalog'
import { toast } from 'sonner'
import { ComponentConfigDialog } from './ComponentConfigDialog'
import { TreeNode } from './modules/TreeNode'
import { useHierarchyData } from './modules/useHierarchyData'
import { useHierarchyDragDrop } from './modules/useHierarchyDragDrop'
import { HierarchyTree } from './ComponentHierarchyEditor/Tree'
import { selectRootNodes } from './ComponentHierarchyEditor/selectors'
export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: boolean }) {
const { pages, selectedPageId, setSelectedPageId, hierarchy, loadHierarchy } = useHierarchyData()
@@ -37,10 +37,7 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
const componentIdPrefix = useId()
const rootNodes = useMemo(
() =>
Object.values(hierarchy)
.filter(node => node.pageId === selectedPageId && !node.parentId)
.sort((a, b) => a.order - b.order),
() => selectRootNodes(hierarchy, selectedPageId),
[hierarchy, selectedPageId]
)
@@ -108,50 +105,6 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
[hierarchy, loadHierarchy]
)
const renderTree = useMemo(
() =>
rootNodes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Cursor size={48} className="mb-4" />
<p>No components yet. Add one from the catalog!</p>
</div>
) : (
<div className="space-y-1">
{rootNodes.map((node) => (
<TreeNode
key={node.id}
node={node}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={setSelectedNodeId}
onToggle={handleToggleNode}
onDelete={handleDeleteNode}
onConfig={setConfigNodeId}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
),
[
expandedNodes,
handleDeleteNode,
handleDragOver,
handleDragStart,
handleDrop,
handleToggleNode,
hierarchy,
rootNodes,
selectedNodeId,
draggingNodeId,
setConfigNodeId,
setSelectedNodeId,
]
)
return (
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-12rem)]">
<div className="col-span-8 space-y-4">
@@ -191,7 +144,20 @@ export function ComponentHierarchyEditor({ nerdMode = false }: { nerdMode?: bool
<CardContent className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
{selectedPageId ? (
renderTree
<HierarchyTree
rootNodes={rootNodes}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
draggingNodeId={draggingNodeId}
onSelect={setSelectedNodeId}
onToggle={handleToggleNode}
onDelete={handleDeleteNode}
onConfig={setConfigNodeId}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<p>Select a page to edit its component hierarchy</p>

View File

@@ -0,0 +1,65 @@
import { Cursor } from '@phosphor-icons/react'
import type React from 'react'
import type { ComponentNode } from '@/lib/database'
import { TreeNode } from '../modules/TreeNode'
interface HierarchyTreeProps {
rootNodes: ComponentNode[]
hierarchy: Record<string, ComponentNode>
selectedNodeId: string | null
expandedNodes: Record<string, boolean>
draggingNodeId: string | null
onSelect: (nodeId: string) => void
onToggle: (nodeId: string) => void
onDelete: (nodeId: string) => Promise<void>
onConfig: (nodeId: string) => void
onDragStart: (event: React.DragEvent, nodeId: string) => void
onDragOver: (event: React.DragEvent, nodeId: string) => void
onDrop: (event: React.DragEvent, nodeId: string) => void
}
export function HierarchyTree({
rootNodes,
hierarchy,
selectedNodeId,
expandedNodes,
draggingNodeId,
onSelect,
onToggle,
onDelete,
onConfig,
onDragStart,
onDragOver,
onDrop,
}: HierarchyTreeProps) {
if (rootNodes.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Cursor size={48} className="mb-4" />
<p>No components yet. Add one from the catalog!</p>
</div>
)
}
return (
<div className="space-y-1">
{rootNodes.map((node) => (
<TreeNode
key={node.id}
node={node}
hierarchy={hierarchy}
selectedNodeId={selectedNodeId}
expandedNodes={expandedNodes}
onSelect={onSelect}
onToggle={onToggle}
onDelete={onDelete}
onConfig={onConfig}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
draggingNodeId={draggingNodeId}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,10 @@
import type { ComponentNode } from '@/lib/database'
export function selectRootNodes(
hierarchy: Record<string, ComponentNode>,
selectedPageId: string | null
): ComponentNode[] {
return Object.values(hierarchy)
.filter(node => node.pageId === selectedPageId && !node.parentId)
.sort((a, b) => a.order - b.order)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useMemo, useState } from 'react'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
ScrollArea,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui'
import { Clock, ShieldWarning, UserSwitch } from '@phosphor-icons/react'
export type AuditSeverity = 'info' | 'warning' | 'critical'
export interface AuditEvent {
id: string
actor: string
action: string
target?: string
timestamp: string | number
severity: AuditSeverity
}
interface AuditTrailProps {
events: AuditEvent[]
showSearch?: boolean
maxRows?: number
}
const SEVERITY_META: Record<AuditSeverity, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
info: { label: 'Info', variant: 'secondary' },
warning: { label: 'Warning', variant: 'default' },
critical: { label: 'Critical', variant: 'destructive' },
}
const formatTime = (value: string | number) => new Date(value).toLocaleString()
export function AuditTrail({ events, showSearch = true, maxRows }: AuditTrailProps) {
const [query, setQuery] = useState('')
const [severity, setSeverity] = useState<AuditSeverity | 'all'>('all')
const filtered = useMemo(() => {
const text = query.toLowerCase()
return events
.filter((event) => {
const matchesText =
!text || `${event.actor} ${event.action} ${event.target ?? ''}`.toLowerCase().includes(text)
const matchesSeverity = severity === 'all' || event.severity === severity
return matchesText && matchesSeverity
})
.slice(0, maxRows ?? events.length)
}, [events, query, severity, maxRows])
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Audit trail</CardTitle>
<CardDescription>Recent security-sensitive actions.</CardDescription>
</div>
<Badge variant="outline" className="gap-1">
<UserSwitch size={14} />
{events.length} events
</Badge>
</div>
{showSearch && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="audit-query">Search</Label>
<Input
id="audit-query"
placeholder="Filter by actor or action"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="audit-severity">Severity</Label>
<select
id="audit-severity"
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={severity}
onChange={(event) => setSeverity(event.target.value as AuditSeverity | 'all')}
>
<option value="all">All events</option>
{Object.entries(SEVERITY_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
</div>
</div>
)}
</CardHeader>
<CardContent>
<ScrollArea className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-36">Timestamp</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-32">Severity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock size={14} />
{formatTime(event.timestamp)}
</div>
</TableCell>
<TableCell className="font-medium">{event.actor}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{event.action}
{event.target && (
<span className="ml-2 rounded bg-muted px-2 py-1 text-xs text-foreground">
{event.target}
</span>
)}
</TableCell>
<TableCell>
<Badge variant={SEVERITY_META[event.severity].variant} className="gap-1">
<ShieldWarning size={14} />
{SEVERITY_META[event.severity].label}
</Badge>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No audit events found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@/components/ui'
import type { UserRole } from '@/lib/level-types'
interface RoleEditorProps {
role: UserRole
onRoleChange: (role: UserRole) => void
isInstanceOwner?: boolean
onInstanceOwnerChange?: (value: boolean) => void
allowedRoles?: UserRole[]
}
const ROLE_INFO: Record<UserRole, { blurb: string; highlights: string[] }> = {
public: {
blurb: 'Read-only access for guest viewers.',
highlights: ['View public resources', 'No authentication needed'],
},
user: {
blurb: 'Standard workspace member with personal settings.',
highlights: ['Create content', 'Access shared libraries'],
},
moderator: {
blurb: 'Content moderator with collaboration tools.',
highlights: ['Manage comments', 'Resolve reports', 'Escalate to admins'],
},
admin: {
blurb: 'Tenant-level administrator controls.',
highlights: ['Invite users', 'Configure pages', 'Reset credentials'],
},
god: {
blurb: 'Power user with platform configuration access.',
highlights: ['Manage integrations', 'Run advanced scripts', 'Override safety flags'],
},
supergod: {
blurb: 'Instance owner with full control.',
highlights: ['Edit system settings', 'Manage tenants', 'Bypass feature gates'],
},
}
const roleLabel = (role: UserRole) => role.charAt(0).toUpperCase() + role.slice(1)
export function RoleEditor({
role,
onRoleChange,
isInstanceOwner,
onInstanceOwnerChange,
allowedRoles,
}: RoleEditorProps) {
const options = allowedRoles ?? (Object.keys(ROLE_INFO) as UserRole[])
return (
<Card>
<CardHeader>
<CardTitle>User role</CardTitle>
<CardDescription>Adjust access level and ownership flags.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={(value) => onRoleChange(value as UserRole)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a role" />
</SelectTrigger>
<SelectContent>
{options.map((value) => (
<SelectItem key={value} value={value}>
<div className="flex items-center justify-between gap-3">
<span>{roleLabel(value)}</span>
<Badge variant={value === 'supergod' || value === 'god' ? 'default' : 'secondary'}>
{value === 'public' ? 'Read only' : 'Level access'}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{roleLabel(role)}</p>
<p className="text-sm text-muted-foreground">{ROLE_INFO[role].blurb}</p>
</div>
<Badge variant="outline">{ROLE_INFO[role].highlights.length} capabilities</Badge>
</div>
<ul className="mt-3 grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
{ROLE_INFO[role].highlights.map((item) => (
<li key={item} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{onInstanceOwnerChange && (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="font-medium">Instance owner</p>
<p className="text-sm text-muted-foreground">
Grants access to backup, billing, and infrastructure settings.
</p>
</div>
<Switch checked={isInstanceOwner} onCheckedChange={onInstanceOwnerChange} />
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
Badge,
Button,
Input,
Label,
ScrollArea,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui'
import { FunnelSimple, PencilSimple, Trash } from '@phosphor-icons/react'
import type { User, UserRole } from '@/lib/level-types'
interface UserListProps {
users: User[]
onEdit?: (user: User) => void
onDelete?: (user: User) => void
compact?: boolean
}
const ROLE_STYLES: Record<UserRole, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
public: { label: 'Public', variant: 'outline' },
user: { label: 'User', variant: 'outline' },
moderator: { label: 'Moderator', variant: 'secondary' },
admin: { label: 'Admin', variant: 'secondary' },
god: { label: 'God', variant: 'default' },
supergod: { label: 'Supergod', variant: 'default' },
}
function initials(value: string) {
return value
.split(' ')
.map((chunk) => chunk[0])
.join('')
.slice(0, 2)
.toUpperCase()
}
export function UserList({ users, onEdit, onDelete, compact }: UserListProps) {
const [query, setQuery] = useState('')
const [role, setRole] = useState<UserRole | 'all'>('all')
const filtered = useMemo(() => {
return users.filter((user) => {
const matchesQuery = `${user.username} ${user.email}`
.toLowerCase()
.includes(query.toLowerCase())
const matchesRole = role === 'all' || user.role === role
return matchesQuery && matchesRole
})
}, [users, query, role])
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label htmlFor="user-search">Search users</Label>
<Input
id="user-search"
placeholder="Search by name or email"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="flex items-center gap-2" htmlFor="role-filter">
<FunnelSimple size={16} /> Role filter
</Label>
<select
id="role-filter"
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={role}
onChange={(event) => setRole(event.target.value as UserRole | 'all')}
>
<option value="all">All roles</option>
{Object.entries(ROLE_STYLES).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
</div>
</div>
<ScrollArea className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-32">Role</TableHead>
<TableHead className="w-40">Joined</TableHead>
{(onEdit || onDelete) && <TableHead className="w-28 text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((user) => (
<TableRow key={user.id} className={compact ? 'text-sm' : undefined}>
<TableCell className="flex items-center gap-3">
<Avatar className="h-9 w-9">
{user.profilePicture && <img alt={user.username} src={user.profilePicture} />}
<AvatarFallback>{initials(user.username)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{user.username}</div>
<div className="text-xs text-muted-foreground">ID: {user.id}</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell>
<Badge variant={ROLE_STYLES[user.role]?.variant}>{ROLE_STYLES[user.role]?.label}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
{(onEdit || onDelete) && (
<TableCell className="flex justify-end gap-2">
{onEdit && (
<Button size="icon" variant="outline" onClick={() => onEdit(user)}>
<PencilSimple size={16} />
</Button>
)}
{onDelete && (
<Button size="icon" variant="ghost" onClick={() => onDelete(user)}>
<Trash size={16} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No users match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</div>
)
}