@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,48 @@
1
+ 'use client';
2
+
3
+ import { useEffect, type ReactNode } from 'react';
4
+
5
+ export interface ModalProps {
6
+ open: boolean;
7
+ onClose: () => void;
8
+ title: string;
9
+ children: ReactNode;
10
+ actions?: ReactNode;
11
+ }
12
+
13
+ export function Modal({ open, onClose, title, children, actions }: ModalProps) {
14
+ useEffect(() => {
15
+ if (!open) return;
16
+
17
+ function handleKey(e: KeyboardEvent) {
18
+ if (e.key === 'Escape') onClose();
19
+ }
20
+
21
+ document.addEventListener('keydown', handleKey);
22
+ return () => document.removeEventListener('keydown', handleKey);
23
+ }, [open, onClose]);
24
+
25
+ if (!open) return null;
26
+
27
+ return (
28
+ <div className="fixed inset-0 z-[60] flex items-center justify-center">
29
+ <div className="fixed inset-0 bg-black/50" onClick={onClose} role="presentation" />
30
+ <div className="relative z-10 w-full max-w-lg rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl">
31
+ <div className="flex items-center justify-between border-b border-[var(--border)] px-6 py-4">
32
+ <h2 className="text-lg font-semibold">{title}</h2>
33
+ <button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--accent)]">
34
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
35
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
36
+ </svg>
37
+ </button>
38
+ </div>
39
+ <div className="px-6 py-4">{children}</div>
40
+ {actions && (
41
+ <div className="flex justify-end gap-2 border-t border-[var(--border)] px-6 py-4">
42
+ {actions}
43
+ </div>
44
+ )}
45
+ </div>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ export interface PaginationProps {
4
+ page: number;
5
+ perPage: number;
6
+ total: number;
7
+ onPageChange: (page: number) => void;
8
+ onPerPageChange: (perPage: number) => void;
9
+ }
10
+
11
+ export function Pagination({ page, perPage, total, onPageChange, onPerPageChange }: PaginationProps) {
12
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
13
+ const start = (page - 1) * perPage + 1;
14
+ const end = Math.min(page * perPage, total);
15
+
16
+ function pageNumbers(): (number | 'ellipsis')[] {
17
+ const pages: (number | 'ellipsis')[] = [];
18
+ for (let i = 1; i <= totalPages; i++) {
19
+ if (i === 1 || i === totalPages || Math.abs(i - page) <= 1) {
20
+ pages.push(i);
21
+ } else if (pages[pages.length - 1] !== 'ellipsis') {
22
+ pages.push('ellipsis');
23
+ }
24
+ }
25
+ return pages;
26
+ }
27
+
28
+ return (
29
+ <div className="flex items-center justify-between text-sm">
30
+ <span className="text-[var(--muted-foreground)]">
31
+ {start}–{end} of {total} results
32
+ </span>
33
+
34
+ <div className="flex items-center gap-2">
35
+ <select
36
+ value={perPage}
37
+ onChange={(e) => { onPerPageChange(Number(e.target.value)); onPageChange(1); }}
38
+ className="rounded-md border border-[var(--border)] bg-[var(--input-background)] px-2 py-1 text-sm"
39
+ >
40
+ {[10, 25, 50, 100].map((n) => (
41
+ <option key={n} value={n}>{n} / page</option>
42
+ ))}
43
+ </select>
44
+
45
+ <nav className="flex items-center gap-1">
46
+ <button
47
+ onClick={() => onPageChange(page - 1)}
48
+ disabled={page <= 1}
49
+ className="rounded-md px-2 py-1 hover:bg-[var(--accent)] disabled:opacity-50"
50
+ >
51
+ Prev
52
+ </button>
53
+ {pageNumbers().map((p, i) =>
54
+ p === 'ellipsis' ? (
55
+ <span key={`e${i}`} className="px-1 text-[var(--muted-foreground)]">…</span>
56
+ ) : (
57
+ <button
58
+ key={p}
59
+ onClick={() => onPageChange(p)}
60
+ className={`min-w-[2rem] rounded-md px-2 py-1 ${
61
+ p === page ? 'bg-[var(--primary)] text-[var(--primary-foreground)]' : 'hover:bg-[var(--accent)]'
62
+ }`}
63
+ >
64
+ {p}
65
+ </button>
66
+ ),
67
+ )}
68
+ <button
69
+ onClick={() => onPageChange(page + 1)}
70
+ disabled={page >= totalPages}
71
+ className="rounded-md px-2 py-1 hover:bg-[var(--accent)] disabled:opacity-50"
72
+ >
73
+ Next
74
+ </button>
75
+ </nav>
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ export interface SearchInputProps {
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ placeholder?: string;
7
+ }
8
+
9
+ export function SearchInput({ value, onChange, placeholder = 'Search...' }: SearchInputProps) {
10
+ return (
11
+ <div className="relative flex-1">
12
+ <svg
13
+ className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]"
14
+ fill="none"
15
+ viewBox="0 0 24 24"
16
+ stroke="currentColor"
17
+ strokeWidth={2}
18
+ >
19
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
20
+ </svg>
21
+ <input
22
+ type="text"
23
+ value={value}
24
+ onChange={(e) => onChange(e.target.value)}
25
+ placeholder={placeholder}
26
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] py-2 pl-9 pr-8 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
27
+ />
28
+ {value && (
29
+ <button
30
+ onClick={() => onChange('')}
31
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
32
+ aria-label="Clear search"
33
+ >
34
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
35
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
36
+ </svg>
37
+ </button>
38
+ )}
39
+ <kbd className="pointer-events-none absolute right-8 top-1/2 hidden -translate-y-1/2 rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)] sm:inline-block">
40
+ ⌘K
41
+ </kbd>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,48 @@
1
+ type SkeletonVariant = 'text' | 'card' | 'table-row';
2
+
3
+ export interface SkeletonProps {
4
+ variant?: SkeletonVariant;
5
+ lines?: number;
6
+ className?: string;
7
+ }
8
+
9
+ export function Skeleton({ variant = 'text', lines = 3, className = '' }: SkeletonProps) {
10
+ if (variant === 'card') {
11
+ return (
12
+ <div className={`animate-pulse rounded-lg border border-[var(--border)] p-5 ${className}`}>
13
+ <div className="mb-3 h-4 w-1/3 rounded bg-[var(--muted)]" />
14
+ <div className="space-y-2">
15
+ <div className="h-3 w-full rounded bg-[var(--muted)]" />
16
+ <div className="h-3 w-2/3 rounded bg-[var(--muted)]" />
17
+ </div>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ if (variant === 'table-row') {
23
+ return (
24
+ <div className={`animate-pulse ${className}`}>
25
+ {Array.from({ length: lines }, (_, i) => (
26
+ <div key={i} className="flex gap-4 border-b border-[var(--border)] px-4 py-3">
27
+ <div className="h-4 w-8 rounded bg-[var(--muted)]" />
28
+ <div className="h-4 flex-1 rounded bg-[var(--muted)]" />
29
+ <div className="h-4 w-20 rounded bg-[var(--muted)]" />
30
+ <div className="h-4 w-24 rounded bg-[var(--muted)]" />
31
+ </div>
32
+ ))}
33
+ </div>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <div className={`animate-pulse space-y-2 ${className}`}>
39
+ {Array.from({ length: lines }, (_, i) => (
40
+ <div
41
+ key={i}
42
+ className="h-3 rounded bg-[var(--muted)]"
43
+ style={{ width: `${Math.max(40, 100 - i * 15)}%` }}
44
+ />
45
+ ))}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+
5
+ type ToastType = 'success' | 'error' | 'warning' | 'info';
6
+
7
+ interface Toast {
8
+ id: string;
9
+ type: ToastType;
10
+ message: string;
11
+ }
12
+
13
+ const typeClasses: Record<ToastType, string> = {
14
+ success: 'border-emerald-500 bg-emerald-50 text-emerald-900 dark:bg-emerald-900/20 dark:text-emerald-300',
15
+ error: 'border-red-500 bg-red-50 text-red-900 dark:bg-red-900/20 dark:text-red-300',
16
+ warning: 'border-amber-500 bg-amber-50 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300',
17
+ info: 'border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300',
18
+ };
19
+
20
+ export function useToast() {
21
+ const [toasts, setToasts] = useState<Toast[]>([]);
22
+
23
+ const addToast = useCallback((type: ToastType, message: string) => {
24
+ const id = crypto.randomUUID();
25
+ setToasts((prev) => [...prev, { id, type, message }]);
26
+ setTimeout(() => {
27
+ setToasts((prev) => prev.filter((t) => t.id !== id));
28
+ }, 5000);
29
+ }, []);
30
+
31
+ const dismissToast = useCallback((id: string) => {
32
+ setToasts((prev) => prev.filter((t) => t.id !== id));
33
+ }, []);
34
+
35
+ return { toasts, addToast, dismissToast };
36
+ }
37
+
38
+ export interface ToastContainerProps {
39
+ toasts: Toast[];
40
+ onDismiss: (id: string) => void;
41
+ }
42
+
43
+ export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
44
+ if (toasts.length === 0) return null;
45
+
46
+ return (
47
+ <div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
48
+ {toasts.map((toast) => (
49
+ <div
50
+ key={toast.id}
51
+ className={`flex items-center gap-3 rounded-md border-l-4 px-4 py-3 shadow-lg ${typeClasses[toast.type]}`}
52
+ >
53
+ <p className="flex-1 text-sm">{toast.message}</p>
54
+ <button
55
+ onClick={() => onDismiss(toast.id)}
56
+ className="shrink-0 opacity-60 hover:opacity-100"
57
+ >
58
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
59
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
60
+ </svg>
61
+ </button>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,24 @@
1
+ export { Button } from './Button.js';
2
+ export type { ButtonProps } from './Button.js';
3
+ export { Badge } from './Badge.js';
4
+ export type { BadgeProps } from './Badge.js';
5
+ export { Avatar } from './Avatar.js';
6
+ export type { AvatarProps } from './Avatar.js';
7
+ export { EmptyState } from './EmptyState.js';
8
+ export type { EmptyStateProps } from './EmptyState.js';
9
+ export { Skeleton } from './Skeleton.js';
10
+ export type { SkeletonProps } from './Skeleton.js';
11
+ export { useToast, ToastContainer } from './Toast.js';
12
+ export type { ToastContainerProps } from './Toast.js';
13
+ export { Modal } from './Modal.js';
14
+ export type { ModalProps } from './Modal.js';
15
+ export { ConfirmDialog } from './ConfirmDialog.js';
16
+ export type { ConfirmDialogProps } from './ConfirmDialog.js';
17
+ export { DataTable } from './DataTable.js';
18
+ export type { DataTableProps } from './DataTable.js';
19
+ export { Pagination } from './Pagination.js';
20
+ export type { PaginationProps } from './Pagination.js';
21
+ export { SearchInput } from './SearchInput.js';
22
+ export type { SearchInputProps } from './SearchInput.js';
23
+ export { CommandPalette } from './CommandPalette.js';
24
+ export type { CommandPaletteProps } from './CommandPalette.js';
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { Button } from '../components/ui/Button.js';
4
+
5
+ export interface ArrayFieldProps {
6
+ label: string;
7
+ value?: any[];
8
+ onChange: (value: any[]) => void;
9
+ fields?: any[];
10
+ helpText?: string;
11
+ }
12
+
13
+ export function ArrayField({ label, value = [], onChange, helpText }: ArrayFieldProps) {
14
+ function addItem() {
15
+ onChange([...value, { id: crypto.randomUUID(), data: {} }]);
16
+ }
17
+
18
+ function removeItem(index: number) {
19
+ onChange(value.filter((_, i) => i !== index));
20
+ }
21
+
22
+ function moveItem(from: number, to: number) {
23
+ if (to < 0 || to >= value.length) return;
24
+ const next = [...value];
25
+ const [moved] = next.splice(from, 1);
26
+ next.splice(to, 0, moved);
27
+ onChange(next);
28
+ }
29
+
30
+ return (
31
+ <div>
32
+ <label className="mb-2 block text-sm font-medium">{label}</label>
33
+
34
+ <div className="space-y-2">
35
+ {value.map((item, index) => (
36
+ <div
37
+ key={item.id ?? index}
38
+ className="flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--card)] p-3"
39
+ >
40
+ <div className="flex flex-col gap-0.5">
41
+ <button
42
+ type="button"
43
+ onClick={() => moveItem(index, index - 1)}
44
+ disabled={index === 0}
45
+ className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-30"
46
+ aria-label="Move up"
47
+ >
48
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
49
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
50
+ </svg>
51
+ </button>
52
+ <button
53
+ type="button"
54
+ onClick={() => moveItem(index, index + 1)}
55
+ disabled={index === value.length - 1}
56
+ className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-30"
57
+ aria-label="Move down"
58
+ >
59
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
60
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
61
+ </svg>
62
+ </button>
63
+ </div>
64
+
65
+ <div className="flex-1 text-sm text-[var(--muted-foreground)]">
66
+ Item {index + 1}
67
+ </div>
68
+
69
+ <button
70
+ type="button"
71
+ onClick={() => removeItem(index)}
72
+ className="rounded p-1 text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--destructive)]"
73
+ aria-label="Remove item"
74
+ >
75
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
76
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
77
+ </svg>
78
+ </button>
79
+ </div>
80
+ ))}
81
+ </div>
82
+
83
+ <Button variant="secondary" size="sm" onClick={addItem} className="mt-2">
84
+ Add Item
85
+ </Button>
86
+
87
+ {helpText && (
88
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
89
+ )}
90
+ </div>
91
+ );
92
+ }