@object-ui/runner 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/dist/assets/AdvancedChartImpl-DZ0-Siqu.js +11 -0
- package/dist/assets/BarChart-C_mJtBYc.js +69 -0
- package/dist/assets/ChartImpl-BcbiBEtK.js +1 -0
- package/dist/assets/KanbanImpl-APLTSaom.js +5 -0
- package/dist/assets/index-BAf0tooB.css +1 -0
- package/dist/assets/index-CW0j9o4j.js +16235 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +37 -0
- package/postcss.config.js +6 -0
- package/src/App.tsx +149 -0
- package/src/LayoutRenderer.tsx +312 -0
- package/src/index.css +76 -0
- package/src/lib/MetadataLoader.ts +95 -0
- package/src/lib/mockDataSource.ts +34 -0
- package/src/main.tsx +10 -0
- package/tailwind.config.js +63 -0
- package/vite.config.ts +29 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Object UI React Example</title>
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CW0j9o4j.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BAf0tooB.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Object UI React Example</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@object-ui/runner",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"description": "Universal Object UI Application Runner",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"class-variance-authority": "^0.7.1",
|
|
9
|
+
"clsx": "^2.1.0",
|
|
10
|
+
"lucide-react": "^0.562.0",
|
|
11
|
+
"react": "^18.2.0",
|
|
12
|
+
"react-dom": "^18.2.0",
|
|
13
|
+
"tailwind-merge": "^2.2.1",
|
|
14
|
+
"tailwindcss-animate": "^1.0.7",
|
|
15
|
+
"@object-ui/components": "0.3.0",
|
|
16
|
+
"@object-ui/core": "0.3.0",
|
|
17
|
+
"@object-ui/react": "0.3.0",
|
|
18
|
+
"@object-ui/types": "0.3.0",
|
|
19
|
+
"@object-ui/plugin-kanban": "0.3.0",
|
|
20
|
+
"@object-ui/plugin-charts": "0.3.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/react": "^18.2.66",
|
|
24
|
+
"@types/react-dom": "^18.2.22",
|
|
25
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
26
|
+
"autoprefixer": "^10.4.19",
|
|
27
|
+
"postcss": "^8.4.38",
|
|
28
|
+
"tailwindcss": "^3.4.1",
|
|
29
|
+
"typescript": "^5.2.2",
|
|
30
|
+
"vite": "^5.2.0"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "vite",
|
|
34
|
+
"build": "vite build",
|
|
35
|
+
"preview": "vite preview"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Ensure this file is treated as a module
|
|
2
|
+
export {};
|
|
3
|
+
|
|
4
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
5
|
+
import '@object-ui/components';
|
|
6
|
+
import '@object-ui/plugin-kanban';
|
|
7
|
+
import '@object-ui/plugin-charts';
|
|
8
|
+
import { PageSchema, AppSchema } from '@object-ui/types';
|
|
9
|
+
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
10
|
+
import { LayoutRenderer } from './LayoutRenderer';
|
|
11
|
+
import { LocalBundleLoader, NetworkLoader, MetadataLoader } from './lib/MetadataLoader';
|
|
12
|
+
|
|
13
|
+
export default function App() {
|
|
14
|
+
const [appConfig, setAppConfig] = useState<AppSchema | null>(null);
|
|
15
|
+
const [pageSchema, setPageSchema] = useState<PageSchema | null>(null);
|
|
16
|
+
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
|
|
20
|
+
// Initialize Loader Strategy
|
|
21
|
+
const loader = useMemo<MetadataLoader>(() => {
|
|
22
|
+
const params = new URLSearchParams(window.location.search);
|
|
23
|
+
const apiUrl = params.get('api');
|
|
24
|
+
|
|
25
|
+
// IF ?api=... is present, use Network Loader
|
|
26
|
+
if (apiUrl) {
|
|
27
|
+
console.log('🔌 Using Network Loader:', apiUrl);
|
|
28
|
+
return new NetworkLoader(apiUrl);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ELSE use bundled files (Local Development)
|
|
32
|
+
console.log('📦 Using Local Bundle Loader');
|
|
33
|
+
return new LocalBundleLoader();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
// --- 1. Load Global Config (once) ---
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const loadApp = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const config = await loader.loadAppConfig();
|
|
41
|
+
if (config) setAppConfig(config);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error("Error loading app config", e);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
loadApp();
|
|
47
|
+
}, [loader]);
|
|
48
|
+
|
|
49
|
+
// --- 2. Route Handling ---
|
|
50
|
+
const handleNavigate = useCallback((to: string) => {
|
|
51
|
+
window.history.pushState({}, '', to);
|
|
52
|
+
setCurrentPath(to);
|
|
53
|
+
window.scrollTo(0, 0);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const onPopState = () => setCurrentPath(window.location.pathname);
|
|
58
|
+
window.addEventListener('popstate', onPopState);
|
|
59
|
+
return () => window.removeEventListener('popstate', onPopState);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
// --- 3. Page Loading Logic ---
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const loadPage = async () => {
|
|
65
|
+
setLoading(true);
|
|
66
|
+
setError(null);
|
|
67
|
+
try {
|
|
68
|
+
const schema = await loader.loadPage(currentPath);
|
|
69
|
+
if (schema) {
|
|
70
|
+
setPageSchema(schema);
|
|
71
|
+
} else {
|
|
72
|
+
// If 404
|
|
73
|
+
setError(`Page not found: ${currentPath}`);
|
|
74
|
+
if (currentPath === '/') {
|
|
75
|
+
setPageSchema({
|
|
76
|
+
type: 'page',
|
|
77
|
+
title: 'Welcome to Object UI',
|
|
78
|
+
body: [{ type: 'div', className: "p-10 text-center text-muted-foreground", body: 'No index page found.' }]
|
|
79
|
+
} as any);
|
|
80
|
+
} else {
|
|
81
|
+
setPageSchema(null);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(err);
|
|
86
|
+
setError("Failed to load page.");
|
|
87
|
+
} finally {
|
|
88
|
+
setLoading(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
loadPage();
|
|
93
|
+
}, [currentPath, loader]);
|
|
94
|
+
|
|
95
|
+
// --- Render ---
|
|
96
|
+
|
|
97
|
+
// 1. Initial App Boot: Show Full Loader (only if app config isn't ready)
|
|
98
|
+
if (!appConfig && loading) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="flex h-screen items-center justify-center text-slate-400">
|
|
101
|
+
<div className="h-6 w-6 animate-spin rounded-full border-2 border-slate-200 border-t-slate-600 mr-2" />
|
|
102
|
+
Loading App...
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Prepare Main Content (Page)
|
|
108
|
+
let mainContent;
|
|
109
|
+
|
|
110
|
+
if (loading) {
|
|
111
|
+
// Page transition loading - shows INSIDE layout
|
|
112
|
+
mainContent = (
|
|
113
|
+
<div className="flex flex-col items-center justify-center h-full text-slate-400 min-h-[50vh]">
|
|
114
|
+
<div className="h-6 w-6 animate-spin rounded-full border-2 border-slate-200 border-t-slate-600 mr-2" />
|
|
115
|
+
Loading Page...
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
} else if (error || !pageSchema) {
|
|
119
|
+
// Error State
|
|
120
|
+
mainContent = (
|
|
121
|
+
<div className="flex flex-col items-center justify-center h-full p-12 text-red-600">
|
|
122
|
+
<h1 className="text-2xl font-bold">404</h1>
|
|
123
|
+
<p className="mt-2 text-slate-600">{error || 'Page not found'}</p>
|
|
124
|
+
<button onClick={() => handleNavigate('/')} className="mt-4 text-blue-600 hover:underline">
|
|
125
|
+
Go Home
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
// Success State
|
|
131
|
+
mainContent = <SchemaRenderer schema={pageSchema} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3. Render Wrapper
|
|
135
|
+
if (appConfig) {
|
|
136
|
+
return (
|
|
137
|
+
<LayoutRenderer app={appConfig} currentPath={currentPath} onNavigate={handleNavigate}>
|
|
138
|
+
{mainContent}
|
|
139
|
+
</LayoutRenderer>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fallback if no appConfig but somehow done loading (e.g. error loading app.json)
|
|
144
|
+
return (
|
|
145
|
+
<div className="object-ui-app">
|
|
146
|
+
{mainContent}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AppSchema } from '@object-ui/types';
|
|
3
|
+
import * as LucideIcons from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuGroup,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuSeparator,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
DropdownMenuShortcut,
|
|
13
|
+
Avatar,
|
|
14
|
+
AvatarImage,
|
|
15
|
+
AvatarFallback,
|
|
16
|
+
Collapsible,
|
|
17
|
+
CollapsibleContent,
|
|
18
|
+
CollapsibleTrigger
|
|
19
|
+
} from '@object-ui/components';
|
|
20
|
+
|
|
21
|
+
interface LayoutRendererProps {
|
|
22
|
+
app: AppSchema;
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
currentPath?: string;
|
|
25
|
+
onNavigate?: (path: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper to resolve icon from string name (e.g. "bar-chart" -> "BarChart")
|
|
29
|
+
const getIcon = (name?: string) => {
|
|
30
|
+
if (!name) return null;
|
|
31
|
+
|
|
32
|
+
// 1. Try direct match (e.g. "Home")
|
|
33
|
+
if ((LucideIcons as any)[name]) return (LucideIcons as any)[name];
|
|
34
|
+
|
|
35
|
+
// 2. Try PascalCase (e.g. "bar-chart" -> "BarChart")
|
|
36
|
+
const pascalName = name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('');
|
|
37
|
+
if ((LucideIcons as any)[pascalName]) return (LucideIcons as any)[pascalName];
|
|
38
|
+
|
|
39
|
+
return LucideIcons.Circle; // Fallback
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const NavItem = ({ item, currentPath, isSidebarOpen, onNavigate, level = 0 }: any) => {
|
|
43
|
+
const isActive = currentPath === item.path;
|
|
44
|
+
const hasActiveChild = item.children?.some((child: any) => child.path === currentPath);
|
|
45
|
+
const [isOpen, setIsOpen] = React.useState(hasActiveChild);
|
|
46
|
+
const Icon = getIcon(item.icon);
|
|
47
|
+
|
|
48
|
+
// Auto-expand if child is active
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
if (hasActiveChild) setIsOpen(true);
|
|
51
|
+
}, [hasActiveChild]);
|
|
52
|
+
|
|
53
|
+
if (item.children && item.children.length > 0) {
|
|
54
|
+
return (
|
|
55
|
+
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full">
|
|
56
|
+
<CollapsibleTrigger className={`flex w-full items-center justify-between py-2 text-sm font-medium rounded-md transition-colors text-muted-foreground hover:bg-muted hover:text-foreground ${isSidebarOpen ? 'px-3' : 'justify-center px-2 cursor-pointer'}`}>
|
|
57
|
+
<div className="flex items-center overflow-hidden">
|
|
58
|
+
{Icon && <Icon className={`h-4 w-4 flex-shrink-0 ${isSidebarOpen ? 'mr-3' : ''}`} />}
|
|
59
|
+
<span className={`whitespace-nowrap overflow-hidden transition-all duration-300 ${isSidebarOpen ? 'opacity-100 w-auto' : 'opacity-0 w-0 hidden'}`}>
|
|
60
|
+
{item.label}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
{isSidebarOpen && (
|
|
64
|
+
<LucideIcons.ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
65
|
+
)}
|
|
66
|
+
</CollapsibleTrigger>
|
|
67
|
+
<CollapsibleContent className="space-y-1 overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
|
68
|
+
{isSidebarOpen && item.children.map((child: any, idx: number) => (
|
|
69
|
+
<NavItem
|
|
70
|
+
key={idx}
|
|
71
|
+
item={child}
|
|
72
|
+
currentPath={currentPath}
|
|
73
|
+
isSidebarOpen={isSidebarOpen}
|
|
74
|
+
onNavigate={onNavigate}
|
|
75
|
+
level={level + 1}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</CollapsibleContent>
|
|
79
|
+
</Collapsible>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<a
|
|
85
|
+
href={item.path || '#'}
|
|
86
|
+
onClick={(e) => item.path && onNavigate(e, item.path)}
|
|
87
|
+
title={!isSidebarOpen ? item.label : undefined}
|
|
88
|
+
className={`flex items-center py-2 text-sm font-medium rounded-md transition-colors ${
|
|
89
|
+
isActive
|
|
90
|
+
? 'bg-primary text-primary-foreground'
|
|
91
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
92
|
+
} ${isSidebarOpen ? 'px-3' : 'justify-center px-2'} ${level > 0 && isSidebarOpen ? 'pl-10' : ''}`}
|
|
93
|
+
>
|
|
94
|
+
{Icon && <Icon className={`h-4 w-4 flex-shrink-0 ${isSidebarOpen ? 'mr-3' : ''} ${isActive ? 'text-primary-foreground' : 'text-muted-foreground group-hover:text-foreground'}`} />}
|
|
95
|
+
<span className={`whitespace-nowrap overflow-hidden transition-all duration-300 ${isSidebarOpen ? 'opacity-100 w-auto' : 'opacity-0 w-0 hidden'}`}>
|
|
96
|
+
{item.label}
|
|
97
|
+
</span>
|
|
98
|
+
</a>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: LayoutRendererProps) => {
|
|
103
|
+
const layout = app.layout || 'sidebar';
|
|
104
|
+
const [isSidbarOpen, setSidebarOpen] = React.useState(true);
|
|
105
|
+
|
|
106
|
+
// Theme management
|
|
107
|
+
const [theme, setTheme] = React.useState<"light" | "dark">("light");
|
|
108
|
+
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
const isDark = localStorage.getItem('theme') === 'dark' ||
|
|
111
|
+
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
112
|
+
setTheme(isDark ? 'dark' : 'light');
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
React.useEffect(() => {
|
|
116
|
+
if (theme === 'dark') {
|
|
117
|
+
document.documentElement.classList.add('dark');
|
|
118
|
+
localStorage.setItem('theme', 'dark');
|
|
119
|
+
} else {
|
|
120
|
+
document.documentElement.classList.remove('dark');
|
|
121
|
+
localStorage.setItem('theme', 'light');
|
|
122
|
+
}
|
|
123
|
+
}, [theme]);
|
|
124
|
+
|
|
125
|
+
const toggleTheme = () => {
|
|
126
|
+
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
if (onNavigate) {
|
|
132
|
+
onNavigate(path);
|
|
133
|
+
} else {
|
|
134
|
+
window.location.href = path;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (layout === 'empty') {
|
|
139
|
+
return <main className={app.className}>{children}</main>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const LogoIcon = app.logo && !app.logo.includes('/') && !app.logo.includes('.') ? getIcon(app.logo) : null;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className={`flex min-h-screen w-full bg-slate-50/50 dark:bg-zinc-950 ${app.className || ''}`}>
|
|
146
|
+
{/* Sidebar - Only if configured */}
|
|
147
|
+
{layout === 'sidebar' && (
|
|
148
|
+
<aside
|
|
149
|
+
className={`
|
|
150
|
+
flex-shrink-0 border-r bg-background hidden md:flex flex-col h-screen sticky top-0 z-30 transition-all duration-300 ease-in-out
|
|
151
|
+
${isSidbarOpen ? 'w-64' : 'w-[70px]'}
|
|
152
|
+
`}
|
|
153
|
+
>
|
|
154
|
+
<div className={`h-14 flex items-center border-b font-semibold text-lg tracking-tight transition-all ${isSidbarOpen ? 'px-6' : 'justify-center px-0'}`}>
|
|
155
|
+
{LogoIcon ? (
|
|
156
|
+
<LogoIcon className="h-6 w-6" />
|
|
157
|
+
) : app.logo ? (
|
|
158
|
+
<img src={app.logo} alt={app.title} className="h-6 w-auto" />
|
|
159
|
+
) : <LucideIcons.Box className="h-6 w-6" />}
|
|
160
|
+
|
|
161
|
+
<span className={`ml-2 whitespace-nowrap overflow-hidden transition-all duration-300 ${isSidbarOpen ? 'opacity-100 w-auto' : 'opacity-0 w-0 hidden'}`}>
|
|
162
|
+
{app.title || app.name || 'Object UI'}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
<nav className="flex-1 p-2 space-y-1 overflow-y-auto overflow-x-hidden">
|
|
166
|
+
{app.menu?.map((item, index) => (
|
|
167
|
+
<NavItem
|
|
168
|
+
key={index}
|
|
169
|
+
item={item}
|
|
170
|
+
currentPath={currentPath}
|
|
171
|
+
isSidebarOpen={isSidbarOpen}
|
|
172
|
+
onNavigate={handleNavClick}
|
|
173
|
+
/>
|
|
174
|
+
))}
|
|
175
|
+
</nav>
|
|
176
|
+
{app.version && isSidbarOpen && (
|
|
177
|
+
<div className="p-4 border-t text-xs text-muted-foreground">
|
|
178
|
+
v{app.version}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</aside>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{/* Main Content Area */}
|
|
185
|
+
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
186
|
+
{/* Header - Always shown in sidebar/header layouts */}
|
|
187
|
+
<header className="h-14 flex items-center justify-between px-4 md:px-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b z-20 sticky top-0">
|
|
188
|
+
<div className="flex items-center gap-4">
|
|
189
|
+
{/* Toggle Sidebar Button */}
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => setSidebarOpen(!isSidbarOpen)}
|
|
192
|
+
className="p-2 -ml-2 text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors"
|
|
193
|
+
>
|
|
194
|
+
<LucideIcons.Menu className="h-5 w-5" />
|
|
195
|
+
</button>
|
|
196
|
+
|
|
197
|
+
{/* Breadcrumbs placeholder or Search */}
|
|
198
|
+
<div className="relative hidden md:block w-96">
|
|
199
|
+
<LucideIcons.Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
200
|
+
<input
|
|
201
|
+
type="text"
|
|
202
|
+
placeholder="Search..."
|
|
203
|
+
className="w-full h-9 pl-9 pr-4 rounded-md border border-input bg-background text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="flex items-center gap-2">
|
|
208
|
+
{/* Theme Toggle */}
|
|
209
|
+
<button
|
|
210
|
+
onClick={toggleTheme}
|
|
211
|
+
className="p-2 text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted"
|
|
212
|
+
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
213
|
+
>
|
|
214
|
+
{theme === 'dark' ? <LucideIcons.Sun className="h-5 w-5" /> : <LucideIcons.Moon className="h-5 w-5" />}
|
|
215
|
+
</button>
|
|
216
|
+
|
|
217
|
+
{/* Global Actions */}
|
|
218
|
+
{app.actions?.filter(a => a.type === 'button').map((action, i) => {
|
|
219
|
+
const Icon = action.icon ? getIcon(action.icon) : null;
|
|
220
|
+
return (
|
|
221
|
+
<button
|
|
222
|
+
key={i}
|
|
223
|
+
className={action.variant === 'ghost' ? "relative p-2 text-muted-foreground hover:text-foreground transition-colors hover:bg-muted rounded-md" : "p-2"}
|
|
224
|
+
title={action.label}
|
|
225
|
+
>
|
|
226
|
+
{Icon && <Icon className="h-5 w-5" />}
|
|
227
|
+
{action.label && !action.icon && <span>{action.label}</span>}
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
})}
|
|
231
|
+
|
|
232
|
+
{/* Fallback Bell if no actions defined, or keep it as specific logic?
|
|
233
|
+
The original code hardcoded a Bell button.
|
|
234
|
+
The app.json defines a 'Bell' button action.
|
|
235
|
+
So I should iterate app.actions for buttons as well.
|
|
236
|
+
*/}
|
|
237
|
+
|
|
238
|
+
{/* Original Bell Logic (Hardcoded in user request? No, it was hardcoded in my previous edit, but app.json has it too)
|
|
239
|
+
Let's check app.json. It has:
|
|
240
|
+
{ "type": "button", "variant": "ghost", "size": "icon", "icon": "Bell" }
|
|
241
|
+
|
|
242
|
+
If I render actions generically, I don't need the hardcoded Bell.
|
|
243
|
+
*/}
|
|
244
|
+
|
|
245
|
+
{(!app.actions || !app.actions.some(a => a.type === 'button')) && (
|
|
246
|
+
<button className="relative p-2 text-muted-foreground hover:text-foreground transition-colors">
|
|
247
|
+
<LucideIcons.Bell className="h-5 w-5" />
|
|
248
|
+
<span className="absolute top-1.5 right-1.5 h-2 w-2 bg-red-600 rounded-full border-2 border-background"></span>
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{app.actions?.filter(a => a.type === 'user').map((userAction, i) => (
|
|
253
|
+
<DropdownMenu key={i}>
|
|
254
|
+
<DropdownMenuTrigger asChild>
|
|
255
|
+
<button className="relative h-8 w-8 rounded-full border bg-muted overflow-hidden focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 hover:opacity-90 transition-opacity">
|
|
256
|
+
<Avatar className="h-full w-full">
|
|
257
|
+
<AvatarImage
|
|
258
|
+
src={userAction.avatar}
|
|
259
|
+
alt={userAction.label || 'User'}
|
|
260
|
+
/>
|
|
261
|
+
<AvatarFallback>
|
|
262
|
+
{userAction.label?.substring(0, 2).toUpperCase() || 'JD'}
|
|
263
|
+
</AvatarFallback>
|
|
264
|
+
</Avatar>
|
|
265
|
+
</button>
|
|
266
|
+
</DropdownMenuTrigger>
|
|
267
|
+
<DropdownMenuContent className="w-56" align="end" forceMount>
|
|
268
|
+
<DropdownMenuLabel className="font-normal">
|
|
269
|
+
<div className="flex flex-col space-y-1">
|
|
270
|
+
<p className="text-sm font-medium leading-none">{userAction.label || 'User'}</p>
|
|
271
|
+
<p className="text-xs leading-none text-muted-foreground">
|
|
272
|
+
{userAction.description || 'user@example.com'}
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
275
|
+
</DropdownMenuLabel>
|
|
276
|
+
<DropdownMenuSeparator />
|
|
277
|
+
<DropdownMenuGroup>
|
|
278
|
+
{userAction.items?.map((item, idx) => {
|
|
279
|
+
if (item.type === 'separator') {
|
|
280
|
+
return <DropdownMenuSeparator key={idx} />;
|
|
281
|
+
}
|
|
282
|
+
return (
|
|
283
|
+
<DropdownMenuItem key={idx} onSelect={() => {
|
|
284
|
+
if ((item as any).onClick) {
|
|
285
|
+
// Handle click logic
|
|
286
|
+
console.log('Clicked', item.label);
|
|
287
|
+
}
|
|
288
|
+
}}>
|
|
289
|
+
{item.label}
|
|
290
|
+
{(item as any).shortcut && (
|
|
291
|
+
<DropdownMenuShortcut>{(item as any).shortcut}</DropdownMenuShortcut>
|
|
292
|
+
)}
|
|
293
|
+
</DropdownMenuItem>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</DropdownMenuGroup>
|
|
297
|
+
</DropdownMenuContent>
|
|
298
|
+
</DropdownMenu>
|
|
299
|
+
))}
|
|
300
|
+
</div>
|
|
301
|
+
</header>
|
|
302
|
+
|
|
303
|
+
{/* Page Content */}
|
|
304
|
+
<main className="flex-1 overflow-auto p-4 md:p-8 scroll-smooth">
|
|
305
|
+
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
306
|
+
{children}
|
|
307
|
+
</div>
|
|
308
|
+
</main>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
};
|
package/src/index.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 222.2 84% 4.9%;
|
|
9
|
+
|
|
10
|
+
--card: 0 0% 100%;
|
|
11
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
12
|
+
|
|
13
|
+
--popover: 0 0% 100%;
|
|
14
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
15
|
+
|
|
16
|
+
--primary: 222.2 47.4% 11.2%;
|
|
17
|
+
--primary-foreground: 210 40% 98%;
|
|
18
|
+
|
|
19
|
+
--secondary: 210 40% 96.1%;
|
|
20
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
21
|
+
|
|
22
|
+
--muted: 210 40% 96.1%;
|
|
23
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
24
|
+
|
|
25
|
+
--accent: 210 40% 96.1%;
|
|
26
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
27
|
+
|
|
28
|
+
--destructive: 0 84.2% 60.2%;
|
|
29
|
+
--destructive-foreground: 210 40% 98%;
|
|
30
|
+
|
|
31
|
+
--border: 214.3 31.8% 91.4%;
|
|
32
|
+
--input: 214.3 31.8% 91.4%;
|
|
33
|
+
--ring: 222.2 84% 4.9%;
|
|
34
|
+
|
|
35
|
+
--radius: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.dark {
|
|
39
|
+
--background: 222.2 84% 4.9%;
|
|
40
|
+
--foreground: 210 40% 98%;
|
|
41
|
+
|
|
42
|
+
--card: 222.2 84% 4.9%;
|
|
43
|
+
--card-foreground: 210 40% 98%;
|
|
44
|
+
|
|
45
|
+
--popover: 222.2 84% 4.9%;
|
|
46
|
+
--popover-foreground: 210 40% 98%;
|
|
47
|
+
|
|
48
|
+
--primary: 210 40% 98%;
|
|
49
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
50
|
+
|
|
51
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
52
|
+
--secondary-foreground: 210 40% 98%;
|
|
53
|
+
|
|
54
|
+
--muted: 217.2 32.6% 17.5%;
|
|
55
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
56
|
+
|
|
57
|
+
--accent: 217.2 32.6% 17.5%;
|
|
58
|
+
--accent-foreground: 210 40% 98%;
|
|
59
|
+
|
|
60
|
+
--destructive: 0 62.8% 30.6%;
|
|
61
|
+
--destructive-foreground: 210 40% 98%;
|
|
62
|
+
|
|
63
|
+
--border: 217.2 32.6% 17.5%;
|
|
64
|
+
--input: 217.2 32.6% 17.5%;
|
|
65
|
+
--ring: 212.7 26.8% 83.9%;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@layer base {
|
|
70
|
+
* {
|
|
71
|
+
@apply border-border;
|
|
72
|
+
}
|
|
73
|
+
body {
|
|
74
|
+
@apply bg-background text-foreground;
|
|
75
|
+
}
|
|
76
|
+
}
|