@actuate-media/cms-admin 0.1.4 → 0.2.1
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 +17 -11
- 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 +2 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +52 -7
- 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 +300 -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,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { X, RotateCcw, Clock, User, Loader2 } from 'lucide-react';
|
|
5
|
+
import { toast } from 'sonner';
|
|
6
|
+
import { cmsApi } from '../lib/api.js';
|
|
7
|
+
|
|
8
|
+
interface Version {
|
|
9
|
+
id: string;
|
|
10
|
+
changeType: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
changedBy?: {
|
|
14
|
+
id: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface VersionHistoryProps {
|
|
21
|
+
collectionSlug: string;
|
|
22
|
+
documentId: string;
|
|
23
|
+
open: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onRestore?: (data: Record<string, unknown>) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function timeAgo(dateStr: string): string {
|
|
29
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
30
|
+
const minutes = Math.floor(diff / 60_000);
|
|
31
|
+
if (minutes < 1) return 'just now';
|
|
32
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
33
|
+
const hours = Math.floor(minutes / 60);
|
|
34
|
+
if (hours < 24) return `${hours}h ago`;
|
|
35
|
+
const days = Math.floor(hours / 24);
|
|
36
|
+
if (days < 30) return `${days}d ago`;
|
|
37
|
+
return new Date(dateStr).toLocaleDateString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CHANGE_TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
|
41
|
+
CREATE: { label: 'Created', color: 'bg-green-100 text-green-800' },
|
|
42
|
+
UPDATE: { label: 'Updated', color: 'bg-blue-100 text-blue-800' },
|
|
43
|
+
DELETE: { label: 'Deleted', color: 'bg-red-100 text-red-800' },
|
|
44
|
+
RESTORE: { label: 'Restored', color: 'bg-purple-100 text-purple-800' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function VersionHistory({ collectionSlug, documentId, open, onClose, onRestore }: VersionHistoryProps) {
|
|
48
|
+
const [versions, setVersions] = useState<Version[]>([]);
|
|
49
|
+
const [loading, setLoading] = useState(false);
|
|
50
|
+
const [restoring, setRestoring] = useState<string | null>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (open) fetchVersions();
|
|
54
|
+
}, [open, collectionSlug, documentId]);
|
|
55
|
+
|
|
56
|
+
async function fetchVersions() {
|
|
57
|
+
setLoading(true);
|
|
58
|
+
const res = await cmsApi<{ versions: Version[]; total: number }>(
|
|
59
|
+
`/collections/${collectionSlug}/${documentId}/versions?pageSize=50`,
|
|
60
|
+
);
|
|
61
|
+
if (res.data) {
|
|
62
|
+
setVersions((res.data as any).versions ?? []);
|
|
63
|
+
}
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function handleRestore(version: Version) {
|
|
68
|
+
setRestoring(version.id);
|
|
69
|
+
const res = await cmsApi<any>(
|
|
70
|
+
`/collections/${collectionSlug}/${documentId}/versions/${version.id}/restore`,
|
|
71
|
+
{ method: 'POST' },
|
|
72
|
+
);
|
|
73
|
+
if (res.error) {
|
|
74
|
+
toast.error(res.error);
|
|
75
|
+
} else {
|
|
76
|
+
toast.success('Version restored');
|
|
77
|
+
if (onRestore && version.data) {
|
|
78
|
+
onRestore(version.data);
|
|
79
|
+
}
|
|
80
|
+
fetchVersions();
|
|
81
|
+
}
|
|
82
|
+
setRestoring(null);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!open) return null;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="fixed inset-0 z-50 flex justify-end">
|
|
89
|
+
<div className="fixed inset-0 bg-black/30" onClick={onClose} />
|
|
90
|
+
<div className="relative w-full max-w-md bg-white shadow-xl flex flex-col animate-in slide-in-from-right">
|
|
91
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
|
92
|
+
<div className="flex items-center gap-2">
|
|
93
|
+
<Clock className="w-5 h-5 text-gray-600" />
|
|
94
|
+
<h2 className="text-lg font-semibold text-gray-900">Version History</h2>
|
|
95
|
+
</div>
|
|
96
|
+
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
|
|
97
|
+
<X className="w-5 h-5 text-gray-500" />
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex-1 overflow-y-auto">
|
|
102
|
+
{loading ? (
|
|
103
|
+
<div className="flex items-center justify-center py-16">
|
|
104
|
+
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
|
105
|
+
</div>
|
|
106
|
+
) : versions.length === 0 ? (
|
|
107
|
+
<div className="text-center py-16 text-sm text-gray-500">
|
|
108
|
+
No version history available
|
|
109
|
+
</div>
|
|
110
|
+
) : (
|
|
111
|
+
<div className="divide-y divide-gray-100">
|
|
112
|
+
{versions.map((version, index) => {
|
|
113
|
+
const typeInfo = CHANGE_TYPE_LABELS[version.changeType] ?? {
|
|
114
|
+
label: version.changeType,
|
|
115
|
+
color: 'bg-gray-100 text-gray-800',
|
|
116
|
+
};
|
|
117
|
+
const isLatest = index === 0;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div key={version.id} className="px-4 py-3 hover:bg-gray-50 transition-colors">
|
|
121
|
+
<div className="flex items-start justify-between gap-3">
|
|
122
|
+
<div className="flex-1 min-w-0">
|
|
123
|
+
<div className="flex items-center gap-2 mb-1">
|
|
124
|
+
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${typeInfo.color}`}>
|
|
125
|
+
{typeInfo.label}
|
|
126
|
+
</span>
|
|
127
|
+
{isLatest && (
|
|
128
|
+
<span className="text-xs text-gray-400 font-medium">Current</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex items-center gap-3 text-xs text-gray-500">
|
|
132
|
+
{version.changedBy && (
|
|
133
|
+
<span className="flex items-center gap-1">
|
|
134
|
+
<User className="w-3 h-3" />
|
|
135
|
+
{version.changedBy.name ?? version.changedBy.email ?? 'Unknown'}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
<span title={new Date(version.createdAt).toLocaleString()}>
|
|
139
|
+
{timeAgo(version.createdAt)}
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
{!isLatest && (
|
|
144
|
+
<button
|
|
145
|
+
onClick={() => handleRestore(version)}
|
|
146
|
+
disabled={restoring === version.id}
|
|
147
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors disabled:opacity-50"
|
|
148
|
+
>
|
|
149
|
+
{restoring === version.id ? (
|
|
150
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
151
|
+
) : (
|
|
152
|
+
<RotateCcw className="w-3.5 h-3.5" />
|
|
153
|
+
)}
|
|
154
|
+
Restore
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type AvatarSize = 'sm' | 'md' | 'lg';
|
|
2
|
+
|
|
3
|
+
export interface AvatarProps {
|
|
4
|
+
src?: string | null;
|
|
5
|
+
name?: string;
|
|
6
|
+
size?: AvatarSize;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const sizeClasses: Record<AvatarSize, string> = {
|
|
10
|
+
sm: 'h-6 w-6 text-[10px]',
|
|
11
|
+
md: 'h-9 w-9 text-xs',
|
|
12
|
+
lg: 'h-12 w-12 text-sm',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function getInitials(name: string): string {
|
|
16
|
+
return name
|
|
17
|
+
.split(' ')
|
|
18
|
+
.map((part) => part.charAt(0))
|
|
19
|
+
.join('')
|
|
20
|
+
.toUpperCase()
|
|
21
|
+
.slice(0, 2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Avatar({ src, name = '', size = 'md' }: AvatarProps) {
|
|
25
|
+
if (src) {
|
|
26
|
+
return (
|
|
27
|
+
<img
|
|
28
|
+
src={src}
|
|
29
|
+
alt={name}
|
|
30
|
+
className={`inline-block shrink-0 rounded-full object-cover ${sizeClasses[size]}`}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
className={`inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--primary)] font-medium text-[var(--primary-foreground)] ${sizeClasses[size]}`}
|
|
38
|
+
>
|
|
39
|
+
{getInitials(name) || '?'}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface BadgeProps {
|
|
2
|
+
status: 'published' | 'draft' | 'archived' | 'scheduled';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const statusClasses: Record<BadgeProps['status'], string> = {
|
|
6
|
+
published: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
|
|
7
|
+
draft: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
|
8
|
+
archived: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
|
|
9
|
+
scheduled: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const statusLabels: Record<BadgeProps['status'], string> = {
|
|
13
|
+
published: 'Published',
|
|
14
|
+
draft: 'Draft',
|
|
15
|
+
archived: 'Archived',
|
|
16
|
+
scheduled: 'Scheduled',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function Badge({ status }: BadgeProps) {
|
|
20
|
+
return (
|
|
21
|
+
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusClasses[status]}`}>
|
|
22
|
+
{statusLabels[status]}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
6
|
+
type ButtonSize = 'sm' | 'md' | 'lg';
|
|
7
|
+
|
|
8
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
variant?: ButtonVariant;
|
|
10
|
+
size?: ButtonSize;
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const variantClasses: Record<ButtonVariant, string> = {
|
|
16
|
+
primary: 'bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90',
|
|
17
|
+
secondary: 'bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80',
|
|
18
|
+
danger: 'bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90',
|
|
19
|
+
ghost: 'bg-transparent hover:bg-[var(--accent)] text-[var(--foreground)]',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const sizeClasses: Record<ButtonSize, string> = {
|
|
23
|
+
sm: 'px-2.5 py-1 text-xs',
|
|
24
|
+
md: 'px-4 py-2 text-sm',
|
|
25
|
+
lg: 'px-6 py-2.5 text-base',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Button({
|
|
29
|
+
variant = 'primary',
|
|
30
|
+
size = 'md',
|
|
31
|
+
loading = false,
|
|
32
|
+
disabled,
|
|
33
|
+
children,
|
|
34
|
+
className = '',
|
|
35
|
+
...rest
|
|
36
|
+
}: ButtonProps) {
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
disabled={disabled || loading}
|
|
40
|
+
className={`inline-flex items-center justify-center gap-2 rounded-[var(--radius)] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
|
41
|
+
{...rest}
|
|
42
|
+
>
|
|
43
|
+
{loading && (
|
|
44
|
+
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
45
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
46
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
47
|
+
</svg>
|
|
48
|
+
)}
|
|
49
|
+
{children}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface CommandItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
group: string;
|
|
9
|
+
onSelect: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CommandPaletteProps {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
items: CommandItem[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CommandPalette({ open, onClose, items }: CommandPaletteProps) {
|
|
19
|
+
const [query, setQuery] = useState('');
|
|
20
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
const filtered = items.filter((item) =>
|
|
24
|
+
item.label.toLowerCase().includes(query.toLowerCase()),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const groups = Array.from(new Set(filtered.map((i) => i.group)));
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (open) {
|
|
31
|
+
setQuery('');
|
|
32
|
+
setActiveIndex(0);
|
|
33
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
34
|
+
}
|
|
35
|
+
}, [open]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!open) return;
|
|
39
|
+
|
|
40
|
+
function handleKey(e: KeyboardEvent) {
|
|
41
|
+
if (e.key === 'Escape') {
|
|
42
|
+
onClose();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (e.key === 'ArrowDown') {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
|
|
48
|
+
}
|
|
49
|
+
if (e.key === 'ArrowUp') {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
52
|
+
}
|
|
53
|
+
if (e.key === 'Enter' && filtered[activeIndex]) {
|
|
54
|
+
filtered[activeIndex].onSelect();
|
|
55
|
+
onClose();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
document.addEventListener('keydown', handleKey);
|
|
60
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
61
|
+
}, [open, onClose, filtered, activeIndex]);
|
|
62
|
+
|
|
63
|
+
if (!open) return null;
|
|
64
|
+
|
|
65
|
+
let flatIndex = -1;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
|
|
69
|
+
<div className="fixed inset-0 bg-black/50" onClick={onClose} role="presentation" />
|
|
70
|
+
<div className="relative z-10 w-full max-w-lg overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--popover)] shadow-2xl">
|
|
71
|
+
<div className="flex items-center border-b border-[var(--border)] px-4">
|
|
72
|
+
<svg className="mr-2 h-4 w-4 text-[var(--muted-foreground)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
73
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
74
|
+
</svg>
|
|
75
|
+
<input
|
|
76
|
+
ref={inputRef}
|
|
77
|
+
type="text"
|
|
78
|
+
value={query}
|
|
79
|
+
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0); }}
|
|
80
|
+
placeholder="Type a command or search..."
|
|
81
|
+
className="flex-1 bg-transparent py-3 text-sm outline-none"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div className="max-h-72 overflow-y-auto py-2">
|
|
86
|
+
{groups.map((group) => (
|
|
87
|
+
<div key={group}>
|
|
88
|
+
<div className="px-4 py-1.5 text-xs font-medium text-[var(--muted-foreground)]">
|
|
89
|
+
{group}
|
|
90
|
+
</div>
|
|
91
|
+
{filtered
|
|
92
|
+
.filter((i) => i.group === group)
|
|
93
|
+
.map((item) => {
|
|
94
|
+
flatIndex++;
|
|
95
|
+
const idx = flatIndex;
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={item.id}
|
|
99
|
+
onClick={() => { item.onSelect(); onClose(); }}
|
|
100
|
+
className={`flex w-full px-4 py-2 text-sm ${
|
|
101
|
+
idx === activeIndex ? 'bg-[var(--accent)]' : ''
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
{item.label}
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
{filtered.length === 0 && (
|
|
111
|
+
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
|
|
112
|
+
No results found.
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Modal } from './Modal.js';
|
|
4
|
+
import { Button } from './Button.js';
|
|
5
|
+
|
|
6
|
+
export interface ConfirmDialogProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onConfirm: () => void;
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
cancelLabel?: string;
|
|
14
|
+
destructive?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ConfirmDialog({
|
|
18
|
+
open,
|
|
19
|
+
onClose,
|
|
20
|
+
onConfirm,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
confirmLabel = 'Confirm',
|
|
24
|
+
cancelLabel = 'Cancel',
|
|
25
|
+
destructive = false,
|
|
26
|
+
}: ConfirmDialogProps) {
|
|
27
|
+
return (
|
|
28
|
+
<Modal
|
|
29
|
+
open={open}
|
|
30
|
+
onClose={onClose}
|
|
31
|
+
title={title}
|
|
32
|
+
actions={
|
|
33
|
+
<>
|
|
34
|
+
<Button variant="ghost" onClick={onClose}>
|
|
35
|
+
{cancelLabel}
|
|
36
|
+
</Button>
|
|
37
|
+
<Button
|
|
38
|
+
variant={destructive ? 'danger' : 'primary'}
|
|
39
|
+
onClick={() => {
|
|
40
|
+
onConfirm();
|
|
41
|
+
onClose();
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{confirmLabel}
|
|
45
|
+
</Button>
|
|
46
|
+
</>
|
|
47
|
+
}
|
|
48
|
+
>
|
|
49
|
+
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
50
|
+
</Modal>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Column {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
sortable?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Row {
|
|
12
|
+
id: string;
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RowAction {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
icon?: React.ReactNode;
|
|
20
|
+
destructive?: boolean;
|
|
21
|
+
onClick: (row: Row) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DataTableProps {
|
|
25
|
+
columns: Column[];
|
|
26
|
+
rows: Row[];
|
|
27
|
+
selectedIds: string[];
|
|
28
|
+
onSelectionChange: (ids: string[]) => void;
|
|
29
|
+
sortField?: string;
|
|
30
|
+
sortDir?: 'asc' | 'desc';
|
|
31
|
+
onSort?: (field: string) => void;
|
|
32
|
+
rowActions?: RowAction[];
|
|
33
|
+
onRowClick?: (row: Row) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DataTable({
|
|
37
|
+
columns,
|
|
38
|
+
rows,
|
|
39
|
+
selectedIds,
|
|
40
|
+
onSelectionChange,
|
|
41
|
+
sortField,
|
|
42
|
+
sortDir,
|
|
43
|
+
onSort,
|
|
44
|
+
rowActions,
|
|
45
|
+
onRowClick,
|
|
46
|
+
}: DataTableProps) {
|
|
47
|
+
const allSelected = rows.length > 0 && selectedIds.length === rows.length;
|
|
48
|
+
|
|
49
|
+
function toggleAll() {
|
|
50
|
+
onSelectionChange(allSelected ? [] : rows.map((r) => r.id));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toggleRow(id: string) {
|
|
54
|
+
onSelectionChange(
|
|
55
|
+
selectedIds.includes(id)
|
|
56
|
+
? selectedIds.filter((s) => s !== id)
|
|
57
|
+
: [...selectedIds, id],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
|
63
|
+
<table className="w-full text-left text-sm">
|
|
64
|
+
<thead className="border-b border-[var(--border)] bg-[var(--muted)]">
|
|
65
|
+
<tr>
|
|
66
|
+
<th className="w-10 px-4 py-3">
|
|
67
|
+
<input
|
|
68
|
+
type="checkbox"
|
|
69
|
+
checked={allSelected}
|
|
70
|
+
onChange={toggleAll}
|
|
71
|
+
className="rounded border-[var(--border)]"
|
|
72
|
+
/>
|
|
73
|
+
</th>
|
|
74
|
+
{columns.map((col) => (
|
|
75
|
+
<th key={col.key} className="px-4 py-3 font-medium">
|
|
76
|
+
{col.sortable && onSort ? (
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => onSort(col.key)}
|
|
79
|
+
className="inline-flex items-center gap-1 hover:text-[var(--foreground)]"
|
|
80
|
+
>
|
|
81
|
+
{col.label}
|
|
82
|
+
{sortField === col.key && (
|
|
83
|
+
<span className="text-xs">{sortDir === 'asc' ? '↑' : '↓'}</span>
|
|
84
|
+
)}
|
|
85
|
+
</button>
|
|
86
|
+
) : (
|
|
87
|
+
col.label
|
|
88
|
+
)}
|
|
89
|
+
</th>
|
|
90
|
+
))}
|
|
91
|
+
{rowActions && rowActions.length > 0 && <th className="w-20 px-4 py-3" />}
|
|
92
|
+
</tr>
|
|
93
|
+
</thead>
|
|
94
|
+
<tbody className="divide-y divide-[var(--border)]">
|
|
95
|
+
{rows.map((row) => (
|
|
96
|
+
<tr
|
|
97
|
+
key={row.id}
|
|
98
|
+
className={`hover:bg-[var(--accent)] ${selectedIds.includes(row.id) ? 'bg-[var(--accent)]' : ''}`}
|
|
99
|
+
onClick={() => onRowClick?.(row)}
|
|
100
|
+
style={onRowClick ? { cursor: 'pointer' } : undefined}
|
|
101
|
+
>
|
|
102
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
103
|
+
<input
|
|
104
|
+
type="checkbox"
|
|
105
|
+
checked={selectedIds.includes(row.id)}
|
|
106
|
+
onChange={() => toggleRow(row.id)}
|
|
107
|
+
className="rounded border-[var(--border)]"
|
|
108
|
+
/>
|
|
109
|
+
</td>
|
|
110
|
+
{columns.map((col) => (
|
|
111
|
+
<td key={col.key} className="px-4 py-3">
|
|
112
|
+
{row[col.key]}
|
|
113
|
+
</td>
|
|
114
|
+
))}
|
|
115
|
+
{rowActions && rowActions.length > 0 && (
|
|
116
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
117
|
+
<RowActionsMenu actions={rowActions} row={row} />
|
|
118
|
+
</td>
|
|
119
|
+
)}
|
|
120
|
+
</tr>
|
|
121
|
+
))}
|
|
122
|
+
</tbody>
|
|
123
|
+
</table>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function RowActionsMenu({ actions, row }: { actions: RowAction[]; row: Row }) {
|
|
129
|
+
const [open, setOpen] = useState(false);
|
|
130
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!open) return;
|
|
134
|
+
function handleClickOutside(e: MouseEvent) {
|
|
135
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
136
|
+
setOpen(false);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
140
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
141
|
+
}, [open]);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="relative flex items-center justify-end gap-1" ref={ref}>
|
|
145
|
+
{actions
|
|
146
|
+
.filter((a) => a.icon)
|
|
147
|
+
.map((action) => (
|
|
148
|
+
<button
|
|
149
|
+
key={action.key}
|
|
150
|
+
onClick={() => action.onClick(row)}
|
|
151
|
+
className="rounded p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
152
|
+
aria-label={action.label}
|
|
153
|
+
title={action.label}
|
|
154
|
+
>
|
|
155
|
+
{action.icon}
|
|
156
|
+
</button>
|
|
157
|
+
))}
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => setOpen((o) => !o)}
|
|
160
|
+
className="rounded p-1 hover:bg-[var(--muted)]"
|
|
161
|
+
aria-label="More actions"
|
|
162
|
+
>
|
|
163
|
+
<MoreVerticalIcon />
|
|
164
|
+
</button>
|
|
165
|
+
{open && (
|
|
166
|
+
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded-md border border-[var(--border)] bg-[var(--popover)] py-1 shadow-lg">
|
|
167
|
+
{actions.map((action) => (
|
|
168
|
+
<button
|
|
169
|
+
key={action.key}
|
|
170
|
+
className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-[var(--accent)] ${
|
|
171
|
+
action.destructive ? 'text-[var(--destructive)]' : ''
|
|
172
|
+
}`}
|
|
173
|
+
onClick={() => {
|
|
174
|
+
action.onClick(row);
|
|
175
|
+
setOpen(false);
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{action.icon && <span className="h-4 w-4">{action.icon}</span>}
|
|
179
|
+
{action.label}
|
|
180
|
+
</button>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function MoreVerticalIcon() {
|
|
189
|
+
return (
|
|
190
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
191
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01" />
|
|
192
|
+
</svg>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { Button } from './Button.js';
|
|
3
|
+
|
|
4
|
+
export interface EmptyStateProps {
|
|
5
|
+
icon?: ReactNode;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
actionLabel?: string;
|
|
9
|
+
onAction?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
15
|
+
{icon && (
|
|
16
|
+
<div className="mb-4 text-[var(--muted-foreground)]">{icon}</div>
|
|
17
|
+
)}
|
|
18
|
+
<h3 className="text-lg font-semibold">{title}</h3>
|
|
19
|
+
{description && (
|
|
20
|
+
<p className="mt-1 max-w-sm text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
21
|
+
)}
|
|
22
|
+
{actionLabel && onAction && (
|
|
23
|
+
<Button variant="primary" onClick={onAction} className="mt-4">
|
|
24
|
+
{actionLabel}
|
|
25
|
+
</Button>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|