mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +00:00
Compare commits
12 Commits
codex/crea
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ab7aac63e | |||
| 1f7c2e637e | |||
| 9c354fdac5 | |||
| f57b41f86d | |||
| 1e9a6271ea | |||
| 7989c700b9 | |||
| 02e7188b20 | |||
| 1523cf735c | |||
| adedf5f70c | |||
| c069bd0540 | |||
| 871b84ebf4 | |||
| db8c01de1b |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
153
frontends/nextjs/src/components/level4/tabs/TabContent.tsx
Normal file
153
frontends/nextjs/src/components/level4/tabs/TabContent.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
59
frontends/nextjs/src/components/level4/tabs/config.ts
Normal file
59
frontends/nextjs/src/components/level4/tabs/config.ts
Normal 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 },
|
||||
]
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user