@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,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
|
+
}
|