@actuate-media/cms-admin 0.1.4 → 0.2.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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -10
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +8 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +207 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface BlockTypeDefinition {
|
|
2
|
+
type: string;
|
|
3
|
+
label: string;
|
|
4
|
+
icon: string;
|
|
5
|
+
description: string;
|
|
6
|
+
fields: { name: string; type: string; label: string; required?: boolean; options?: { value: string; label: string }[] }[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const PRESET_BLOCKS: BlockTypeDefinition[] = [
|
|
10
|
+
{
|
|
11
|
+
type: 'hero',
|
|
12
|
+
label: 'Hero',
|
|
13
|
+
icon: 'layout',
|
|
14
|
+
description: 'Full-width hero section with heading and CTA',
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'heading', type: 'text', label: 'Heading', required: true },
|
|
17
|
+
{ name: 'subheading', type: 'text', label: 'Subheading' },
|
|
18
|
+
{ name: 'buttonText', type: 'text', label: 'Button Text' },
|
|
19
|
+
{ name: 'buttonUrl', type: 'text', label: 'Button URL' },
|
|
20
|
+
{ name: 'backgroundImage', type: 'media', label: 'Background Image' },
|
|
21
|
+
{ name: 'alignment', type: 'select', label: 'Alignment', options: [
|
|
22
|
+
{ value: 'left', label: 'Left' },
|
|
23
|
+
{ value: 'center', label: 'Center' },
|
|
24
|
+
{ value: 'right', label: 'Right' },
|
|
25
|
+
]},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'cta',
|
|
30
|
+
label: 'Call to Action',
|
|
31
|
+
icon: 'megaphone',
|
|
32
|
+
description: 'Attention-grabbing CTA section',
|
|
33
|
+
fields: [
|
|
34
|
+
{ name: 'heading', type: 'text', label: 'Heading', required: true },
|
|
35
|
+
{ name: 'description', type: 'text', label: 'Description' },
|
|
36
|
+
{ name: 'buttonText', type: 'text', label: 'Button Text', required: true },
|
|
37
|
+
{ name: 'buttonUrl', type: 'text', label: 'Button URL', required: true },
|
|
38
|
+
{ name: 'style', type: 'select', label: 'Style', options: [
|
|
39
|
+
{ value: 'primary', label: 'Primary' },
|
|
40
|
+
{ value: 'secondary', label: 'Secondary' },
|
|
41
|
+
{ value: 'outline', label: 'Outline' },
|
|
42
|
+
]},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'columns',
|
|
47
|
+
label: 'Columns',
|
|
48
|
+
icon: 'columns',
|
|
49
|
+
description: '2 or 3 column layout with rich text',
|
|
50
|
+
fields: [
|
|
51
|
+
{ name: 'columnCount', type: 'select', label: 'Columns', options: [
|
|
52
|
+
{ value: '2', label: '2 Columns' },
|
|
53
|
+
{ value: '3', label: '3 Columns' },
|
|
54
|
+
]},
|
|
55
|
+
{ name: 'column1', type: 'richText', label: 'Column 1' },
|
|
56
|
+
{ name: 'column2', type: 'richText', label: 'Column 2' },
|
|
57
|
+
{ name: 'column3', type: 'richText', label: 'Column 3' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
type: 'imageText',
|
|
62
|
+
label: 'Image + Text',
|
|
63
|
+
icon: 'image',
|
|
64
|
+
description: 'Image alongside text content',
|
|
65
|
+
fields: [
|
|
66
|
+
{ name: 'image', type: 'media', label: 'Image', required: true },
|
|
67
|
+
{ name: 'content', type: 'richText', label: 'Content', required: true },
|
|
68
|
+
{ name: 'imagePosition', type: 'select', label: 'Image Position', options: [
|
|
69
|
+
{ value: 'left', label: 'Left' },
|
|
70
|
+
{ value: 'right', label: 'Right' },
|
|
71
|
+
]},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'quote',
|
|
76
|
+
label: 'Quote',
|
|
77
|
+
icon: 'quote',
|
|
78
|
+
description: 'Blockquote with attribution',
|
|
79
|
+
fields: [
|
|
80
|
+
{ name: 'text', type: 'text', label: 'Quote Text', required: true },
|
|
81
|
+
{ name: 'author', type: 'text', label: 'Author' },
|
|
82
|
+
{ name: 'role', type: 'text', label: 'Author Role' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: 'video',
|
|
87
|
+
label: 'Video Embed',
|
|
88
|
+
icon: 'play',
|
|
89
|
+
description: 'Embedded video from YouTube or Vimeo',
|
|
90
|
+
fields: [
|
|
91
|
+
{ name: 'url', type: 'text', label: 'Video URL', required: true },
|
|
92
|
+
{ name: 'caption', type: 'text', label: 'Caption' },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { FieldRenderer } from './FieldRenderer.js';
|
|
2
|
+
export type { FieldRendererProps, FieldDefinition } from './FieldRenderer.js';
|
|
3
|
+
export { TextField } from './TextField.js';
|
|
4
|
+
export { RichTextField } from './RichTextField.js';
|
|
5
|
+
export { SlugField } from './SlugField.js';
|
|
6
|
+
export { SelectField } from './SelectField.js';
|
|
7
|
+
export { MediaField } from './MediaField.js';
|
|
8
|
+
export { RelationshipField } from './RelationshipField.js';
|
|
9
|
+
export { DateField } from './DateField.js';
|
|
10
|
+
export { ToggleField } from './ToggleField.js';
|
|
11
|
+
export { ArrayField } from './ArrayField.js';
|
|
12
|
+
export { BlockBuilderField } from './BlockBuilderField.js';
|
|
13
|
+
export { PRESET_BLOCKS } from './block-types.js';
|
|
14
|
+
export type { BlockTypeDefinition } from './block-types.js';
|
|
15
|
+
export { GroupField } from './GroupField.js';
|
|
16
|
+
export { NavBuilderField } from './NavBuilderField.js';
|
|
17
|
+
export { NumberField } from './NumberField.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ContentLock {
|
|
6
|
+
lockedBy: string | null;
|
|
7
|
+
lockedAt: Date | null;
|
|
8
|
+
isLocked: boolean;
|
|
9
|
+
isLockedByMe: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useContentLock(documentId: string | undefined, userId: string): ContentLock & {
|
|
13
|
+
acquireLock: () => Promise<boolean>;
|
|
14
|
+
releaseLock: () => Promise<void>;
|
|
15
|
+
} {
|
|
16
|
+
const [lock, setLock] = useState<ContentLock>({
|
|
17
|
+
lockedBy: null,
|
|
18
|
+
lockedAt: null,
|
|
19
|
+
isLocked: false,
|
|
20
|
+
isLockedByMe: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const acquireLock = useCallback(async () => {
|
|
24
|
+
if (!documentId) return false;
|
|
25
|
+
setLock({
|
|
26
|
+
lockedBy: userId,
|
|
27
|
+
lockedAt: new Date(),
|
|
28
|
+
isLocked: true,
|
|
29
|
+
isLockedByMe: true,
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}, [documentId, userId]);
|
|
33
|
+
|
|
34
|
+
const releaseLock = useCallback(async () => {
|
|
35
|
+
setLock({
|
|
36
|
+
lockedBy: null,
|
|
37
|
+
lockedAt: null,
|
|
38
|
+
isLocked: false,
|
|
39
|
+
isLockedByMe: false,
|
|
40
|
+
});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
return () => {
|
|
45
|
+
if (lock.isLockedByMe) {
|
|
46
|
+
releaseLock();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}, [lock.isLockedByMe, releaseLock]);
|
|
50
|
+
|
|
51
|
+
return { ...lock, acquireLock, releaseLock };
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useDebounce<T>(value: T, delayMs: number): T {
|
|
6
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
|
|
10
|
+
return () => clearTimeout(timer);
|
|
11
|
+
}, [value, delayMs]);
|
|
12
|
+
|
|
13
|
+
return debouncedValue;
|
|
14
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ShortcutMap {
|
|
6
|
+
[combo: string]: (e: KeyboardEvent) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useKeyboardShortcuts(shortcuts: ShortcutMap) {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
12
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
13
|
+
const parts: string[] = [];
|
|
14
|
+
|
|
15
|
+
if (meta) parts.push('mod');
|
|
16
|
+
if (e.shiftKey) parts.push('shift');
|
|
17
|
+
if (e.altKey) parts.push('alt');
|
|
18
|
+
parts.push(e.key.toLowerCase());
|
|
19
|
+
|
|
20
|
+
const combo = parts.join('+');
|
|
21
|
+
const handler = shortcuts[combo];
|
|
22
|
+
|
|
23
|
+
if (handler) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
handler(e);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
30
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
31
|
+
}, [shortcuts]);
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export { AdminRoot } from './AdminRoot.js';
|
|
2
|
+
export type { AdminRootProps } from './AdminRoot.js';
|
|
3
|
+
|
|
4
|
+
export { Layout } from './layout/Layout.js';
|
|
5
|
+
export type { LayoutProps } from './layout/Layout.js';
|
|
6
|
+
export { Sidebar } from './layout/Sidebar.js';
|
|
7
|
+
export type { SidebarProps } from './layout/Sidebar.js';
|
|
8
|
+
export { Header } from './layout/Header.js';
|
|
9
|
+
export type { HeaderProps } from './layout/Header.js';
|
|
10
|
+
|
|
11
|
+
export { Dashboard } from './views/Dashboard.js';
|
|
12
|
+
export { Posts } from './views/Posts.js';
|
|
13
|
+
export { Pages } from './views/Pages.js';
|
|
14
|
+
export { PostEditor } from './views/PostEditor.js';
|
|
15
|
+
export { PageEditor } from './views/PageEditor.js';
|
|
16
|
+
export { MediaBrowser } from './views/MediaBrowser.js';
|
|
17
|
+
export { Forms } from './views/Forms.js';
|
|
18
|
+
export { FormEditor } from './views/FormEditor.js';
|
|
19
|
+
export type { FormEditorProps } from './views/FormEditor.js';
|
|
20
|
+
export { FormSubmissions } from './views/FormSubmissions.js';
|
|
21
|
+
export { Redirects } from './views/Redirects.js';
|
|
22
|
+
export { Users } from './views/Users.js';
|
|
23
|
+
export { Settings } from './views/Settings.js';
|
|
24
|
+
export { SEO } from './views/SEO.js';
|
|
25
|
+
export { SetupWizard } from './views/SetupWizard.js';
|
|
26
|
+
export type { SetupWizardProps } from './views/SetupWizard.js';
|
|
27
|
+
export { Login } from './views/Login.js';
|
|
28
|
+
export type { LoginProps, CaptchaConfig } from './views/Login.js';
|
|
29
|
+
export { CollectionList } from './views/CollectionList.js';
|
|
30
|
+
export { DocumentEdit } from './views/DocumentEdit.js';
|
|
31
|
+
|
|
32
|
+
export { Breadcrumbs } from './components/Breadcrumbs.js';
|
|
33
|
+
export { CommandPalette } from './components/CommandPalette.js';
|
|
34
|
+
export { ErrorBoundary } from './components/ErrorBoundary.js';
|
|
35
|
+
export { FolderTree } from './components/FolderTree.js';
|
|
36
|
+
export type { FolderTreeProps, FolderNode, FolderSelection, SmartFolder } from './components/FolderTree.js';
|
|
37
|
+
export { TipTapEditor } from './components/TipTapEditor.js';
|
|
38
|
+
export { SEOPanel } from './components/SEOPanel.js';
|
|
39
|
+
export type { SEOData, SEOPanelProps } from './components/SEOPanel.js';
|
|
40
|
+
export { LivePreview } from './components/LivePreview.js';
|
|
41
|
+
export { VersionHistory } from './components/VersionHistory.js';
|
|
42
|
+
export type { VersionHistoryProps } from './components/VersionHistory.js';
|
|
43
|
+
export { MediaPickerModal } from './components/MediaPickerModal.js';
|
|
44
|
+
export type { MediaPickerModalProps } from './components/MediaPickerModal.js';
|
|
45
|
+
export { ThemeProvider, useTheme } from './components/ThemeProvider.js';
|
|
46
|
+
export { FocalPointPicker } from './components/FocalPointPicker.js';
|
|
47
|
+
export { PresenceIndicator } from './components/PresenceIndicator.js';
|
|
48
|
+
export { LocaleProvider, useLocale } from './components/LocaleProvider.js';
|
|
49
|
+
export type { LocaleProviderProps } from './components/LocaleProvider.js';
|
|
50
|
+
export { LocaleSwitcher } from './components/LocaleSwitcher.js';
|
|
51
|
+
export { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js';
|
|
52
|
+
|
|
53
|
+
export { cmsApi, setApiBase, ensureCsrfToken } from './lib/api.js';
|
|
54
|
+
export { useApiData } from './lib/useApiData.js';
|
|
55
|
+
export type { UseApiDataResult } from './lib/useApiData.js';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Search, Bell, User, ChevronDown, Menu, Sun, Moon } from 'lucide-react';
|
|
5
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
6
|
+
import { CommandPalette } from '../components/CommandPalette.js';
|
|
7
|
+
import { useTheme } from '../components/ThemeProvider.js';
|
|
8
|
+
import { LocaleSwitcher } from '../components/LocaleSwitcher.js';
|
|
9
|
+
|
|
10
|
+
export interface HeaderProps {
|
|
11
|
+
onToggleSidebar: () => void;
|
|
12
|
+
session?: any;
|
|
13
|
+
onNavigate: (path: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
|
|
17
|
+
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
|
18
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
19
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
20
|
+
|
|
21
|
+
const toggleTheme = () => {
|
|
22
|
+
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<header className="h-14 border-b border-border bg-background flex items-center justify-between px-4 gap-4">
|
|
28
|
+
<button
|
|
29
|
+
onClick={onToggleSidebar}
|
|
30
|
+
className="lg:hidden p-2 hover:bg-accent rounded-lg transition-colors"
|
|
31
|
+
aria-label="Toggle sidebar"
|
|
32
|
+
>
|
|
33
|
+
<Menu className="w-5 h-5 text-foreground" strokeWidth={2} />
|
|
34
|
+
</button>
|
|
35
|
+
|
|
36
|
+
<div className="flex items-center lg:hidden">
|
|
37
|
+
<span className="text-lg font-semibold text-foreground">Actuate</span>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className="flex-1" />
|
|
41
|
+
|
|
42
|
+
<div className="flex items-center gap-2 sm:gap-3">
|
|
43
|
+
<div className="hidden md:block relative">
|
|
44
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
45
|
+
<input
|
|
46
|
+
type="text"
|
|
47
|
+
placeholder="Search... (⌘K)"
|
|
48
|
+
value={searchQuery}
|
|
49
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
50
|
+
onFocus={() => setShowCommandPalette(true)}
|
|
51
|
+
className="w-64 pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-input-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => setShowCommandPalette(true)}
|
|
57
|
+
className="md:hidden p-2 hover:bg-accent rounded-lg transition-colors"
|
|
58
|
+
aria-label="Search"
|
|
59
|
+
>
|
|
60
|
+
<Search className="w-5 h-5 text-muted-foreground" />
|
|
61
|
+
</button>
|
|
62
|
+
|
|
63
|
+
<LocaleSwitcher />
|
|
64
|
+
|
|
65
|
+
<button
|
|
66
|
+
onClick={toggleTheme}
|
|
67
|
+
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
|
68
|
+
aria-label="Toggle theme"
|
|
69
|
+
>
|
|
70
|
+
{resolvedTheme === 'dark' ? (
|
|
71
|
+
<Sun className="w-5 h-5 text-muted-foreground" />
|
|
72
|
+
) : (
|
|
73
|
+
<Moon className="w-5 h-5 text-muted-foreground" />
|
|
74
|
+
)}
|
|
75
|
+
</button>
|
|
76
|
+
|
|
77
|
+
<button className="p-2 hover:bg-accent rounded-lg transition-colors relative" aria-label="Notifications">
|
|
78
|
+
<Bell className="w-5 h-5 text-muted-foreground" />
|
|
79
|
+
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-destructive rounded-full" />
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
<DropdownMenu.Root>
|
|
83
|
+
<DropdownMenu.Trigger asChild>
|
|
84
|
+
<button className="flex items-center gap-2 p-1.5 hover:bg-accent rounded-lg transition-colors">
|
|
85
|
+
<div className="w-8 h-8 bg-linear-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
|
86
|
+
<span className="text-white text-sm font-medium">
|
|
87
|
+
{session?.user?.name?.charAt(0)?.toUpperCase() ?? 'A'}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
<ChevronDown className="w-4 h-4 text-muted-foreground hidden sm:block" />
|
|
91
|
+
</button>
|
|
92
|
+
</DropdownMenu.Trigger>
|
|
93
|
+
|
|
94
|
+
<DropdownMenu.Portal>
|
|
95
|
+
<DropdownMenu.Content
|
|
96
|
+
className="min-w-[200px] bg-popover text-popover-foreground rounded-lg border border-border shadow-lg p-1 z-50"
|
|
97
|
+
align="end"
|
|
98
|
+
sideOffset={5}
|
|
99
|
+
>
|
|
100
|
+
<div className="px-3 py-2 border-b border-border">
|
|
101
|
+
<p className="text-sm font-medium">
|
|
102
|
+
{session?.user?.name ?? 'Admin User'}
|
|
103
|
+
</p>
|
|
104
|
+
<p className="text-xs text-muted-foreground">
|
|
105
|
+
{session?.user?.email ?? 'admin@example.com'}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
<DropdownMenu.Item className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent rounded cursor-pointer outline-none">
|
|
109
|
+
<User className="w-4 h-4" />
|
|
110
|
+
Profile
|
|
111
|
+
</DropdownMenu.Item>
|
|
112
|
+
<DropdownMenu.Item
|
|
113
|
+
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent rounded cursor-pointer outline-none"
|
|
114
|
+
onSelect={() => onNavigate('/settings')}
|
|
115
|
+
>
|
|
116
|
+
Settings
|
|
117
|
+
</DropdownMenu.Item>
|
|
118
|
+
<DropdownMenu.Separator className="h-px bg-border my-1" />
|
|
119
|
+
<DropdownMenu.Item className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent rounded cursor-pointer outline-none">
|
|
120
|
+
Logout
|
|
121
|
+
</DropdownMenu.Item>
|
|
122
|
+
</DropdownMenu.Content>
|
|
123
|
+
</DropdownMenu.Portal>
|
|
124
|
+
</DropdownMenu.Root>
|
|
125
|
+
</div>
|
|
126
|
+
</header>
|
|
127
|
+
|
|
128
|
+
<CommandPalette
|
|
129
|
+
open={showCommandPalette}
|
|
130
|
+
onOpenChange={setShowCommandPalette}
|
|
131
|
+
onNavigate={onNavigate}
|
|
132
|
+
/>
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, type ReactNode } from 'react';
|
|
4
|
+
import { Sidebar } from './Sidebar.js';
|
|
5
|
+
import { Header } from './Header.js';
|
|
6
|
+
import { Breadcrumbs } from '../components/Breadcrumbs.js';
|
|
7
|
+
import { Toaster } from 'sonner';
|
|
8
|
+
|
|
9
|
+
export interface LayoutProps {
|
|
10
|
+
config: any;
|
|
11
|
+
session: any;
|
|
12
|
+
currentPath: string;
|
|
13
|
+
onNavigate: (path: string) => void;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Layout({ config, session, currentPath, onNavigate, children }: LayoutProps) {
|
|
18
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
19
|
+
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setMobileSidebarOpen(false);
|
|
23
|
+
}, [currentPath]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handleResize = () => {
|
|
27
|
+
if (window.innerWidth < 1024) {
|
|
28
|
+
setMobileSidebarOpen(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener('resize', handleResize);
|
|
32
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="h-screen flex overflow-hidden bg-background text-foreground">
|
|
37
|
+
<Toaster position="bottom-right" />
|
|
38
|
+
|
|
39
|
+
{mobileSidebarOpen && (
|
|
40
|
+
<div
|
|
41
|
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
|
42
|
+
onClick={() => setMobileSidebarOpen(false)}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
className={`fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 lg:transform-none ${
|
|
48
|
+
mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
<Sidebar
|
|
52
|
+
collapsed={sidebarCollapsed}
|
|
53
|
+
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
54
|
+
currentPath={currentPath}
|
|
55
|
+
onNavigate={onNavigate}
|
|
56
|
+
config={config}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
61
|
+
<Header
|
|
62
|
+
onToggleSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
|
63
|
+
session={session}
|
|
64
|
+
onNavigate={onNavigate}
|
|
65
|
+
/>
|
|
66
|
+
|
|
67
|
+
<Breadcrumbs currentPath={currentPath} onNavigate={onNavigate} />
|
|
68
|
+
|
|
69
|
+
<main className="flex-1 overflow-auto bg-background">
|
|
70
|
+
<div className="h-full">
|
|
71
|
+
{children}
|
|
72
|
+
</div>
|
|
73
|
+
</main>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|