@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.
Files changed (95) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +17 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts +2 -1
  12. package/dist/views/Dashboard.d.ts.map +1 -1
  13. package/dist/views/Dashboard.js +52 -7
  14. package/dist/views/Dashboard.js.map +1 -1
  15. package/package.json +10 -5
  16. package/src/AdminRoot.tsx +312 -0
  17. package/src/__tests__/lib/search.test.ts +138 -0
  18. package/src/__tests__/lib/utils.test.ts +19 -0
  19. package/src/__tests__/router/match-route.test.ts +47 -0
  20. package/src/__tests__/router/strip-base.test.ts +30 -0
  21. package/src/components/Breadcrumbs.tsx +92 -0
  22. package/src/components/CommandPalette.tsx +384 -0
  23. package/src/components/ErrorBoundary.tsx +52 -0
  24. package/src/components/FocalPointPicker.tsx +54 -0
  25. package/src/components/FolderTree.tsx +427 -0
  26. package/src/components/LivePreview.tsx +136 -0
  27. package/src/components/LocaleProvider.tsx +51 -0
  28. package/src/components/LocaleSwitcher.tsx +51 -0
  29. package/src/components/MediaPickerModal.tsx +183 -0
  30. package/src/components/PresenceIndicator.tsx +71 -0
  31. package/src/components/SEOPanel.tsx +767 -0
  32. package/src/components/ThemeProvider.tsx +98 -0
  33. package/src/components/TipTapEditor.tsx +469 -0
  34. package/src/components/VersionHistory.tsx +167 -0
  35. package/src/components/ui/Avatar.tsx +42 -0
  36. package/src/components/ui/Badge.tsx +25 -0
  37. package/src/components/ui/Button.tsx +52 -0
  38. package/src/components/ui/CommandPalette.tsx +119 -0
  39. package/src/components/ui/ConfirmDialog.tsx +52 -0
  40. package/src/components/ui/DataTable.tsx +194 -0
  41. package/src/components/ui/EmptyState.tsx +29 -0
  42. package/src/components/ui/Modal.tsx +48 -0
  43. package/src/components/ui/Pagination.tsx +79 -0
  44. package/src/components/ui/SearchInput.tsx +44 -0
  45. package/src/components/ui/Skeleton.tsx +48 -0
  46. package/src/components/ui/Toast.tsx +66 -0
  47. package/src/components/ui/index.ts +24 -0
  48. package/src/fields/ArrayField.tsx +92 -0
  49. package/src/fields/BlockBuilderField.tsx +421 -0
  50. package/src/fields/DateField.tsx +41 -0
  51. package/src/fields/FieldRenderer.tsx +84 -0
  52. package/src/fields/GroupField.tsx +41 -0
  53. package/src/fields/MediaField.tsx +48 -0
  54. package/src/fields/NavBuilderField.tsx +78 -0
  55. package/src/fields/NumberField.tsx +45 -0
  56. package/src/fields/RelationshipField.tsx +245 -0
  57. package/src/fields/RichTextField.tsx +26 -0
  58. package/src/fields/SelectField.tsx +117 -0
  59. package/src/fields/SlugField.tsx +65 -0
  60. package/src/fields/TextField.tsx +48 -0
  61. package/src/fields/ToggleField.tsx +36 -0
  62. package/src/fields/block-types.ts +95 -0
  63. package/src/fields/index.ts +17 -0
  64. package/src/hooks/useContentLock.ts +52 -0
  65. package/src/hooks/useDebounce.ts +14 -0
  66. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  67. package/src/index.ts +55 -0
  68. package/src/layout/Header.tsx +135 -0
  69. package/src/layout/Layout.tsx +77 -0
  70. package/src/layout/Sidebar.tsx +216 -0
  71. package/src/lib/api.ts +67 -0
  72. package/src/lib/search.ts +59 -0
  73. package/src/lib/useApiData.ts +95 -0
  74. package/src/lib/utils.ts +6 -0
  75. package/src/router/index.ts +81 -0
  76. package/src/styles/build-input.css +11 -0
  77. package/src/styles/tailwind.css +11 -6
  78. package/src/styles/theme.css +182 -181
  79. package/src/views/CollectionList.tsx +270 -0
  80. package/src/views/Dashboard.tsx +300 -0
  81. package/src/views/DocumentEdit.tsx +377 -0
  82. package/src/views/FormEditor.tsx +533 -0
  83. package/src/views/FormSubmissions.tsx +316 -0
  84. package/src/views/Forms.tsx +106 -0
  85. package/src/views/Login.tsx +322 -0
  86. package/src/views/MediaBrowser.tsx +774 -0
  87. package/src/views/PageEditor.tsx +192 -0
  88. package/src/views/Pages.tsx +354 -0
  89. package/src/views/PostEditor.tsx +251 -0
  90. package/src/views/Posts.tsx +243 -0
  91. package/src/views/Redirects.tsx +293 -0
  92. package/src/views/SEO.tsx +458 -0
  93. package/src/views/Settings.tsx +811 -0
  94. package/src/views/SetupWizard.tsx +207 -0
  95. 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
+ }