@kyro-cms/admin 0.1.6 → 0.1.7
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/README.md +149 -51
- package/package.json +53 -6
- package/src/collections/auth/index.ts +2 -2
- package/src/collections/portfolio/index.ts +343 -0
- package/src/components/ActionBar.tsx +153 -16
- package/src/components/Admin.tsx +136 -27
- package/src/components/ApiExplorer.tsx +325 -0
- package/src/components/ApiKeysManager.tsx +563 -0
- package/src/components/AuditLogsPage.tsx +664 -0
- package/src/components/AutoForm.tsx +1417 -661
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +3 -3
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +199 -57
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +786 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +191 -53
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +149 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/UserManagement.tsx +204 -0
- package/src/components/VersionHistoryPanel.tsx +3 -3
- package/src/components/WebhookManager.tsx +608 -0
- package/src/components/blocks/AccordionBlock.tsx +97 -0
- package/src/components/blocks/ArrayBlock.tsx +75 -0
- package/src/components/blocks/BlockEditModal.MARKER +12 -0
- package/src/components/blocks/BlockEditModal.tsx +774 -0
- package/src/components/blocks/ButtonBlock.tsx +165 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +66 -0
- package/src/components/blocks/ColumnsBlock.tsx +151 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +64 -0
- package/src/components/blocks/HeadingBlock.tsx +81 -0
- package/src/components/blocks/HeroBlock.tsx +157 -0
- package/src/components/blocks/ImageBlock.tsx +83 -0
- package/src/components/blocks/LinkBlock.tsx +71 -0
- package/src/components/blocks/ListBlock.tsx +39 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +279 -0
- package/src/components/blocks/VStackBlock.tsx +75 -0
- package/src/components/blocks/VideoBlock.tsx +45 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/CheckboxField.tsx +15 -9
- package/src/components/fields/CodeField.tsx +234 -0
- package/src/components/fields/DateField.tsx +38 -11
- package/src/components/fields/EditorClient.tsx +271 -0
- package/src/components/fields/FileField.tsx +390 -0
- package/src/components/fields/HybridContentField.tsx +109 -0
- package/src/components/fields/ImageField.tsx +429 -0
- package/src/components/fields/JSONField.tsx +361 -0
- package/src/components/fields/MarkdownField.tsx +282 -0
- package/src/components/fields/NumberField.tsx +42 -12
- package/src/components/fields/PortableTextField.tsx +143 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipField.tsx +231 -59
- package/src/components/fields/SelectField.tsx +25 -15
- package/src/components/fields/TextField.tsx +45 -14
- package/src/components/fields/extensions/blockComponents.tsx +237 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +13 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +2 -2
- package/src/components/ui/Badge.tsx +9 -4
- package/src/components/ui/BlockDrawer.tsx +79 -0
- package/src/components/ui/Button.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +362 -0
- package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
- package/src/components/ui/Dropdown.tsx +1 -1
- package/src/components/ui/Modal.tsx +37 -12
- package/src/components/ui/PromptModal.tsx +94 -0
- package/src/components/ui/SlidePanel.tsx +43 -16
- package/src/components/ui/Toast.tsx +80 -14
- package/src/env.d.ts +16 -0
- package/src/env.ts +20 -0
- package/src/index.ts +0 -1
- package/src/layouts/AdminLayout.astro +164 -170
- package/src/layouts/AuthLayout.astro +23 -6
- package/src/lib/MediaService.ts +541 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +22 -6
- package/src/lib/dataStore.ts +132 -74
- package/src/lib/db/adapter.ts +54 -0
- package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
- package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
- package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
- package/src/lib/db/index.ts +449 -0
- package/src/lib/db/mongodb-adapter.ts +207 -0
- package/src/lib/db/mongodb-auth-adapter.ts +305 -0
- package/src/lib/db/schema/mysql-auth.ts +113 -0
- package/src/lib/db/schema/mysql-content.ts +20 -0
- package/src/lib/db/schema/postgres-auth.ts +116 -0
- package/src/lib/db/schema/postgres-content.ts +35 -0
- package/src/lib/db/schema/postgres-media.ts +52 -0
- package/src/lib/db/schema/postgres-settings.ts +11 -0
- package/src/lib/db/schema/sqlite-auth.ts +112 -0
- package/src/lib/db/schema/sqlite-content.ts +20 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/middleware.ts +70 -11
- package/src/pages/[collection]/[id].astro +178 -122
- package/src/pages/[collection]/index.astro +24 -156
- package/src/pages/admin/api-explorer.astro +98 -0
- package/src/pages/admin/graphql-explorer.astro +40 -0
- package/src/pages/admin/graphql.astro +97 -0
- package/src/pages/admin/index.astro +200 -139
- package/src/pages/admin/keys.astro +8 -0
- package/src/pages/admin/rest-playground.astro +44 -0
- package/src/pages/admin/webhooks.astro +8 -0
- package/src/pages/api/[collection]/[id]/publish.ts +44 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +36 -0
- package/src/pages/api/[collection]/[id].ts +102 -159
- package/src/pages/api/[collection]/index.ts +151 -230
- package/src/pages/api/auth/[id].ts +48 -69
- package/src/pages/api/auth/audit-logs.ts +20 -43
- package/src/pages/api/auth/login.ts +159 -45
- package/src/pages/api/auth/logout.ts +42 -24
- package/src/pages/api/auth/refresh.ts +119 -0
- package/src/pages/api/auth/register.ts +110 -40
- package/src/pages/api/auth/users.ts +22 -97
- package/src/pages/api/collections.ts +59 -0
- package/src/pages/api/globals/[slug]/test.ts +172 -0
- package/src/pages/api/globals/[slug].ts +42 -0
- package/src/pages/api/graphql.ts +90 -0
- package/src/pages/api/health.ts +417 -40
- package/src/pages/api/keys/[id].ts +26 -0
- package/src/pages/api/keys/index.ts +75 -0
- package/src/pages/api/media/[id].ts +309 -0
- package/src/pages/api/media/folders.ts +609 -0
- package/src/pages/api/media/index.ts +146 -0
- package/src/pages/api/media/resize.ts +267 -0
- package/src/pages/api/search.ts +82 -0
- package/src/pages/api/slug-availability.ts +70 -0
- package/src/pages/api/storage-config.ts +20 -0
- package/src/pages/api/storage-status.ts +206 -0
- package/src/pages/api/upload.ts +334 -0
- package/src/pages/api/webhooks/index.ts +71 -0
- package/src/pages/audit/index.astro +2 -104
- package/src/pages/login.astro +11 -11
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +13 -13
- package/src/pages/roles/index.astro +21 -21
- package/src/pages/settings/[slug].astro +162 -0
- package/src/pages/settings/index.astro +9 -0
- package/src/pages/users/[id].astro +29 -21
- package/src/pages/users/index.astro +22 -17
- package/src/pages/users/new.astro +18 -17
- package/src/styles/main.css +553 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
2
|
+
import { Spinner } from "./ui/Spinner";
|
|
3
|
+
import { ConfirmModal } from "./ui/Modal";
|
|
4
|
+
|
|
5
|
+
export interface FieldConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
options?: { value: string; label: string }[];
|
|
11
|
+
admin?: {
|
|
12
|
+
hidden?: boolean;
|
|
13
|
+
readonly?: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CollectionConfig {
|
|
18
|
+
slug: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
singularLabel?: string;
|
|
21
|
+
fields: FieldConfig[];
|
|
22
|
+
timestamps?: boolean;
|
|
23
|
+
admin?: {
|
|
24
|
+
description?: string;
|
|
25
|
+
defaultColumns?: string[];
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FilterConfig {
|
|
30
|
+
field: string;
|
|
31
|
+
operator:
|
|
32
|
+
| "equals"
|
|
33
|
+
| "contains"
|
|
34
|
+
| "gt"
|
|
35
|
+
| "lt"
|
|
36
|
+
| "gte"
|
|
37
|
+
| "lte"
|
|
38
|
+
| "between"
|
|
39
|
+
| "in";
|
|
40
|
+
value: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SortConfig {
|
|
44
|
+
field: string;
|
|
45
|
+
direction: "asc" | "desc";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EnhancedListViewProps {
|
|
49
|
+
collection: CollectionConfig;
|
|
50
|
+
collectionSlug: string;
|
|
51
|
+
initialDocs?: any[];
|
|
52
|
+
initialTotal?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function EnhancedListView({
|
|
56
|
+
collection,
|
|
57
|
+
collectionSlug,
|
|
58
|
+
initialDocs = [],
|
|
59
|
+
initialTotal = 0,
|
|
60
|
+
}: EnhancedListViewProps) {
|
|
61
|
+
const onCreate = () => (window.location.href = `/${collectionSlug}/new`);
|
|
62
|
+
const onEdit = (id: string) =>
|
|
63
|
+
(window.location.href = `/${collectionSlug}/${id}`);
|
|
64
|
+
const [docs, setDocs] = useState<any[]>(initialDocs);
|
|
65
|
+
const [totalDocs, setTotalDocs] = useState(initialTotal);
|
|
66
|
+
const [loading, setLoading] = useState(false);
|
|
67
|
+
const [page, setPage] = useState(1);
|
|
68
|
+
const [limit, setLimit] = useState(10);
|
|
69
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
70
|
+
const [search, setSearch] = useState("");
|
|
71
|
+
const [filters, setFilters] = useState<FilterConfig[]>([]);
|
|
72
|
+
|
|
73
|
+
const addFilter = () => {
|
|
74
|
+
setFilters([...filters, { field: "", operator: "equals", value: "" }]);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const clearAll = () => {
|
|
78
|
+
setSearch("");
|
|
79
|
+
setFilters([]);
|
|
80
|
+
setSort(null);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const removeFilter = (index: number) => {
|
|
84
|
+
setFilters(filters.filter((_, i) => i !== index));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const updateFilter = (index: number, updates: Partial<FilterConfig>) => {
|
|
88
|
+
setFilters(filters.map((f, i) => (i === index ? { ...f, ...updates } : f)));
|
|
89
|
+
};
|
|
90
|
+
const [sort, setSort] = useState<SortConfig | null>(null);
|
|
91
|
+
const [showFilters, setShowFilters] = useState(false);
|
|
92
|
+
const [showColumns, setShowColumns] = useState(false);
|
|
93
|
+
|
|
94
|
+
const allFields = useMemo(
|
|
95
|
+
() =>
|
|
96
|
+
collection.fields.filter(
|
|
97
|
+
(f) => f.name && !f.admin?.hidden && f.name !== "id",
|
|
98
|
+
),
|
|
99
|
+
[collection.fields],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
|
|
103
|
+
const defaultCols = allFields.slice(0, 4).map((f) => f.name);
|
|
104
|
+
return new Set(defaultCols);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
108
|
+
open: boolean;
|
|
109
|
+
count: number;
|
|
110
|
+
ids?: string[];
|
|
111
|
+
}>({ open: false, count: 0 });
|
|
112
|
+
|
|
113
|
+
const displayFields = useMemo(
|
|
114
|
+
() => allFields.filter((f) => visibleColumns.has(f.name)),
|
|
115
|
+
[allFields, visibleColumns],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const fetchDocs = useCallback(async () => {
|
|
119
|
+
setLoading(true);
|
|
120
|
+
try {
|
|
121
|
+
const params = new URLSearchParams({
|
|
122
|
+
page: page.toString(),
|
|
123
|
+
limit: limit.toString(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (search) params.append("search", search);
|
|
127
|
+
if (sort) params.append("sort", sort.field);
|
|
128
|
+
if (sort) params.append("order", sort.direction);
|
|
129
|
+
if (filters.length > 0) {
|
|
130
|
+
params.append("filters", JSON.stringify(filters));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const response = await fetch(
|
|
134
|
+
`/api/${collectionSlug}?${params}&t=${Date.now()}`,
|
|
135
|
+
);
|
|
136
|
+
if (!response.ok) throw new Error("Failed to load");
|
|
137
|
+
const result = await response.json();
|
|
138
|
+
setDocs(result.docs || []);
|
|
139
|
+
setTotalDocs(result.totalDocs || 0);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error("Failed to load docs:", error);
|
|
142
|
+
} finally {
|
|
143
|
+
setLoading(false);
|
|
144
|
+
}
|
|
145
|
+
}, [collectionSlug, page, limit, search, sort, filters]);
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
fetchDocs();
|
|
149
|
+
}, [fetchDocs]);
|
|
150
|
+
|
|
151
|
+
const handleSelectAll = () => {
|
|
152
|
+
if (selectedIds.size === docs.length) {
|
|
153
|
+
setSelectedIds(new Set());
|
|
154
|
+
} else {
|
|
155
|
+
setSelectedIds(new Set(docs.map((d) => d.id)));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const handleSelectOne = (id: string) => {
|
|
160
|
+
const newSet = new Set(selectedIds);
|
|
161
|
+
if (newSet.has(id)) {
|
|
162
|
+
newSet.delete(id);
|
|
163
|
+
} else {
|
|
164
|
+
newSet.add(id);
|
|
165
|
+
}
|
|
166
|
+
setSelectedIds(newSet);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const handleBulkDelete = async () => {
|
|
170
|
+
setDeleteConfirm({ open: true, count: selectedIds.size });
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const confirmBulkDelete = async () => {
|
|
174
|
+
try {
|
|
175
|
+
await Promise.all(
|
|
176
|
+
Array.from(selectedIds).map((id) =>
|
|
177
|
+
fetch(`/api/${collectionSlug}/${id}`, { method: "DELETE" }),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
setSelectedIds(new Set());
|
|
181
|
+
fetchDocs();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error("Bulk delete failed:", error);
|
|
184
|
+
}
|
|
185
|
+
setDeleteConfirm({ open: false, count: 0 });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const totalPages = Math.ceil(totalDocs / limit);
|
|
189
|
+
const hasActiveFilters = search || filters.length > 0 || sort;
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div className="space-y-6">
|
|
194
|
+
<ConfirmModal
|
|
195
|
+
open={deleteConfirm.open}
|
|
196
|
+
onClose={() => setDeleteConfirm({ open: false, count: 0 })}
|
|
197
|
+
onConfirm={confirmBulkDelete}
|
|
198
|
+
title="Delete Documents"
|
|
199
|
+
message={`Are you sure you want to delete ${deleteConfirm.count} document(s)? This cannot be undone.`}
|
|
200
|
+
confirmLabel="Delete"
|
|
201
|
+
variant="danger"
|
|
202
|
+
/>
|
|
203
|
+
{/* Header */}
|
|
204
|
+
<div className="surface-tile p-6 flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
|
205
|
+
<div>
|
|
206
|
+
<h1 className="text-2xl font-black tracking-tight text-[var(--kyro-text-primary)]">
|
|
207
|
+
{collection.label || collectionSlug}
|
|
208
|
+
</h1>
|
|
209
|
+
<p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
|
|
210
|
+
{collection.admin?.description ||
|
|
211
|
+
`Manage your ${collection.label || collectionSlug}`}
|
|
212
|
+
{totalDocs > 0 && (
|
|
213
|
+
<span className="ml-2 font-bold">· {totalDocs} documents</span>
|
|
214
|
+
)}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
<button type="button"
|
|
218
|
+
onClick={onCreate}
|
|
219
|
+
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold transition-all hover:opacity-90 active:scale-95 shadow-lg"
|
|
220
|
+
>
|
|
221
|
+
<svg
|
|
222
|
+
className="w-4 h-4"
|
|
223
|
+
fill="none"
|
|
224
|
+
stroke="currentColor"
|
|
225
|
+
viewBox="0 0 24 24"
|
|
226
|
+
>
|
|
227
|
+
<path
|
|
228
|
+
strokeLinecap="round"
|
|
229
|
+
strokeLinejoin="round"
|
|
230
|
+
strokeWidth="2.5"
|
|
231
|
+
d="M12 5v14M5 12h14"
|
|
232
|
+
/>
|
|
233
|
+
</svg>
|
|
234
|
+
Create{" "}
|
|
235
|
+
{collection.singularLabel || collection.label || collectionSlug}
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Toolbar */}
|
|
240
|
+
<div className="surface-tile p-4 flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
|
241
|
+
{/* Search */}
|
|
242
|
+
<div className="relative flex-1 max-w-md">
|
|
243
|
+
<svg
|
|
244
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-secondary)]"
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke="currentColor"
|
|
247
|
+
viewBox="0 0 24 24"
|
|
248
|
+
>
|
|
249
|
+
<path
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
strokeWidth="2"
|
|
253
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
254
|
+
/>
|
|
255
|
+
</svg>
|
|
256
|
+
<input
|
|
257
|
+
type="text"
|
|
258
|
+
placeholder="Search..."
|
|
259
|
+
value={search}
|
|
260
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
261
|
+
className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-xl text-sm font-medium text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
266
|
+
{/* Filter Toggle */}
|
|
267
|
+
<button type="button"
|
|
268
|
+
onClick={() => setShowFilters(!showFilters)}
|
|
269
|
+
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-sm transition-all ${showFilters || filters.length > 0
|
|
270
|
+
? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
|
|
271
|
+
: "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
272
|
+
}`}
|
|
273
|
+
>
|
|
274
|
+
<svg
|
|
275
|
+
className="w-4 h-4"
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke="currentColor"
|
|
278
|
+
viewBox="0 0 24 24"
|
|
279
|
+
>
|
|
280
|
+
<path
|
|
281
|
+
strokeLinecap="round"
|
|
282
|
+
strokeLinejoin="round"
|
|
283
|
+
strokeWidth="2"
|
|
284
|
+
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
|
285
|
+
/>
|
|
286
|
+
</svg>
|
|
287
|
+
Filters
|
|
288
|
+
{filters.length > 0 && (
|
|
289
|
+
<span className="ml-1 px-1.5 py-0.5 bg-[var(--kyro-sidebar-text-active)] text-[var(--kyro-sidebar-active)] rounded-full text-xs">
|
|
290
|
+
{filters.length}
|
|
291
|
+
</span>
|
|
292
|
+
)}
|
|
293
|
+
</button>
|
|
294
|
+
|
|
295
|
+
{/* Column Toggle */}
|
|
296
|
+
<div className="relative">
|
|
297
|
+
<button type="button"
|
|
298
|
+
onClick={() => setShowColumns(!showColumns)}
|
|
299
|
+
className="flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-sm bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-all"
|
|
300
|
+
>
|
|
301
|
+
<svg
|
|
302
|
+
className="w-4 h-4"
|
|
303
|
+
fill="none"
|
|
304
|
+
stroke="currentColor"
|
|
305
|
+
viewBox="0 0 24 24"
|
|
306
|
+
>
|
|
307
|
+
<path
|
|
308
|
+
strokeLinecap="round"
|
|
309
|
+
strokeLinejoin="round"
|
|
310
|
+
strokeWidth="2"
|
|
311
|
+
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
|
|
312
|
+
/>
|
|
313
|
+
</svg>
|
|
314
|
+
Columns
|
|
315
|
+
</button>
|
|
316
|
+
{showColumns && (
|
|
317
|
+
<div className="absolute right-0 top-full mt-2 w-56 surface-tile border border-[var(--kyro-border)] rounded-xl shadow-xl z-50 overflow-hidden">
|
|
318
|
+
<div className="p-3 border-b border-[var(--kyro-border)]">
|
|
319
|
+
<span className="text-xs font-bold uppercase tracking-wider text-[var(--kyro-text-secondary)]">
|
|
320
|
+
Toggle Columns
|
|
321
|
+
</span>
|
|
322
|
+
</div>
|
|
323
|
+
<div className="p-2 max-h-64 overflow-y-auto">
|
|
324
|
+
{allFields.map((field) => (
|
|
325
|
+
<label
|
|
326
|
+
key={field.name}
|
|
327
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--kyro-surface-accent)] cursor-pointer"
|
|
328
|
+
>
|
|
329
|
+
<input
|
|
330
|
+
type="checkbox"
|
|
331
|
+
checked={visibleColumns.has(field.name)}
|
|
332
|
+
onChange={() => toggleColumn(field.name)}
|
|
333
|
+
className="w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
|
|
334
|
+
/>
|
|
335
|
+
<span className="text-sm font-medium text-[var(--kyro-text-primary)]">
|
|
336
|
+
{field.label || field.name}
|
|
337
|
+
</span>
|
|
338
|
+
</label>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
{/* Clear All */}
|
|
346
|
+
{hasActiveFilters && (
|
|
347
|
+
<button type="button"
|
|
348
|
+
onClick={clearAll}
|
|
349
|
+
className="px-4 py-2 rounded-xl font-bold text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
|
|
350
|
+
>
|
|
351
|
+
Clear All
|
|
352
|
+
</button>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{/* Filter Panel */}
|
|
358
|
+
{showFilters && (
|
|
359
|
+
<div className="surface-tile p-4 border-l-4 border-[var(--kyro-sidebar-active)]">
|
|
360
|
+
<div className="flex items-center justify-between mb-4">
|
|
361
|
+
<h3 className="font-bold text-[var(--kyro-text-primary)]">
|
|
362
|
+
Advanced Filters
|
|
363
|
+
</h3>
|
|
364
|
+
<button type="button"
|
|
365
|
+
onClick={addFilter}
|
|
366
|
+
className="flex items-center gap-2 px-3 py-1.5 text-sm font-bold text-[var(--kyro-sidebar-active)] hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all"
|
|
367
|
+
>
|
|
368
|
+
<svg
|
|
369
|
+
className="w-4 h-4"
|
|
370
|
+
fill="none"
|
|
371
|
+
stroke="currentColor"
|
|
372
|
+
viewBox="0 0 24 24"
|
|
373
|
+
>
|
|
374
|
+
<path
|
|
375
|
+
strokeLinecap="round"
|
|
376
|
+
strokeLinejoin="round"
|
|
377
|
+
strokeWidth="2"
|
|
378
|
+
d="M12 5v14M5 12h14"
|
|
379
|
+
/>
|
|
380
|
+
</svg>
|
|
381
|
+
Add Filter
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
<div className="space-y-3">
|
|
385
|
+
{filters.map((filter, index) => (
|
|
386
|
+
<div key={index} className="flex flex-wrap gap-2 items-center">
|
|
387
|
+
<select
|
|
388
|
+
value={filter.field}
|
|
389
|
+
onChange={(e) =>
|
|
390
|
+
updateFilter(index, { field: e.target.value })
|
|
391
|
+
}
|
|
392
|
+
className="px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
|
|
393
|
+
>
|
|
394
|
+
{allFields.map((field) => (
|
|
395
|
+
<option key={field.name} value={field.name}>
|
|
396
|
+
{field.label || field.name}
|
|
397
|
+
</option>
|
|
398
|
+
))}
|
|
399
|
+
</select>
|
|
400
|
+
<select
|
|
401
|
+
value={filter.operator}
|
|
402
|
+
onChange={(e) =>
|
|
403
|
+
updateFilter(index, {
|
|
404
|
+
operator: e.target.value as FilterConfig["operator"],
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
className="px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
|
|
408
|
+
>
|
|
409
|
+
<option value="equals">Equals</option>
|
|
410
|
+
<option value="contains">Contains</option>
|
|
411
|
+
<option value="gt">Greater than</option>
|
|
412
|
+
<option value="lt">Less than</option>
|
|
413
|
+
<option value="gte">Greater or equal</option>
|
|
414
|
+
<option value="lte">Less or equal</option>
|
|
415
|
+
</select>
|
|
416
|
+
<input
|
|
417
|
+
type="text"
|
|
418
|
+
value={filter.value}
|
|
419
|
+
onChange={(e) =>
|
|
420
|
+
updateFilter(index, { value: e.target.value })
|
|
421
|
+
}
|
|
422
|
+
placeholder="Value..."
|
|
423
|
+
className="flex-1 min-w-[150px] px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
|
|
424
|
+
/>
|
|
425
|
+
<button type="button"
|
|
426
|
+
onClick={() => removeFilter(index)}
|
|
427
|
+
className="p-2 text-[var(--kyro-text-muted)] hover:text-red-500 transition-colors"
|
|
428
|
+
>
|
|
429
|
+
<svg
|
|
430
|
+
className="w-4 h-4"
|
|
431
|
+
fill="none"
|
|
432
|
+
stroke="currentColor"
|
|
433
|
+
viewBox="0 0 24 24"
|
|
434
|
+
>
|
|
435
|
+
<path
|
|
436
|
+
strokeLinecap="round"
|
|
437
|
+
strokeLinejoin="round"
|
|
438
|
+
strokeWidth="2"
|
|
439
|
+
d="M6 18L18 6M6 6l12 12"
|
|
440
|
+
/>
|
|
441
|
+
</svg>
|
|
442
|
+
</button>
|
|
443
|
+
</div>
|
|
444
|
+
))}
|
|
445
|
+
{filters.length === 0 && (
|
|
446
|
+
<p className="text-sm text-[var(--kyro-text-muted)]">
|
|
447
|
+
No filters applied. Click "Add Filter" to create one.
|
|
448
|
+
</p>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{/* Bulk Actions */}
|
|
455
|
+
{selectedIds.size > 0 && (
|
|
456
|
+
<div className="surface-tile p-4 flex items-center justify-between border-l-4 border-[var(--kyro-sidebar-active)]">
|
|
457
|
+
<span className="text-sm font-bold text-[var(--kyro-text-primary)]">
|
|
458
|
+
{selectedIds.size} selected
|
|
459
|
+
</span>
|
|
460
|
+
<div className="flex gap-2">
|
|
461
|
+
<button type="button"
|
|
462
|
+
onClick={handleBulkDelete}
|
|
463
|
+
className="flex items-center gap-2 px-4 py-2 bg-red-500 text-white rounded-lg font-bold text-sm hover:bg-red-600 transition-all"
|
|
464
|
+
>
|
|
465
|
+
<svg
|
|
466
|
+
className="w-4 h-4"
|
|
467
|
+
fill="none"
|
|
468
|
+
stroke="currentColor"
|
|
469
|
+
viewBox="0 0 24 24"
|
|
470
|
+
>
|
|
471
|
+
<path
|
|
472
|
+
strokeLinecap="round"
|
|
473
|
+
strokeLinejoin="round"
|
|
474
|
+
strokeWidth="2"
|
|
475
|
+
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"
|
|
476
|
+
/>
|
|
477
|
+
</svg>
|
|
478
|
+
Delete Selected
|
|
479
|
+
</button>
|
|
480
|
+
<button type="button"
|
|
481
|
+
onClick={() => setSelectedIds(new Set())}
|
|
482
|
+
className="px-4 py-2 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] font-bold text-sm transition-all"
|
|
483
|
+
>
|
|
484
|
+
Cancel
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
|
|
490
|
+
{/* Data Table */}
|
|
491
|
+
<div className="surface-tile overflow-hidden">
|
|
492
|
+
{loading ? (
|
|
493
|
+
<div className="flex items-center justify-center py-20">
|
|
494
|
+
<Spinner />
|
|
495
|
+
</div>
|
|
496
|
+
) : docs.length === 0 ? (
|
|
497
|
+
<div className="flex flex-col items-center justify-center py-16 px-8">
|
|
498
|
+
<div className="w-16 h-16 rounded-2xl bg-[var(--kyro-surface-accent)] flex items-center justify-center mb-4">
|
|
499
|
+
<svg
|
|
500
|
+
className="w-8 h-8 text-[var(--kyro-text-muted)]"
|
|
501
|
+
fill="none"
|
|
502
|
+
stroke="currentColor"
|
|
503
|
+
viewBox="0 0 24 24"
|
|
504
|
+
>
|
|
505
|
+
<path
|
|
506
|
+
strokeLinecap="round"
|
|
507
|
+
strokeLinejoin="round"
|
|
508
|
+
strokeWidth="1.5"
|
|
509
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
510
|
+
/>
|
|
511
|
+
</svg>
|
|
512
|
+
</div>
|
|
513
|
+
<p className="font-bold text-[var(--kyro-text-primary)] text-base">
|
|
514
|
+
No documents found
|
|
515
|
+
</p>
|
|
516
|
+
<p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
|
|
517
|
+
{hasActiveFilters
|
|
518
|
+
? "Try adjusting your filters or search query."
|
|
519
|
+
: `Get started by creating your first ${(collection.singularLabel || collection.label || collectionSlug).toLowerCase()}.`}
|
|
520
|
+
</p>
|
|
521
|
+
{!hasActiveFilters && (
|
|
522
|
+
<button type="button"
|
|
523
|
+
onClick={onCreate}
|
|
524
|
+
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg font-bold text-sm shadow-md"
|
|
525
|
+
>
|
|
526
|
+
<svg
|
|
527
|
+
className="w-3.5 h-3.5"
|
|
528
|
+
fill="none"
|
|
529
|
+
stroke="currentColor"
|
|
530
|
+
viewBox="0 0 24 24"
|
|
531
|
+
>
|
|
532
|
+
<path
|
|
533
|
+
strokeLinecap="round"
|
|
534
|
+
strokeLinejoin="round"
|
|
535
|
+
strokeWidth="2.5"
|
|
536
|
+
d="M12 5v14M5 12h14"
|
|
537
|
+
/>
|
|
538
|
+
</svg>
|
|
539
|
+
Create{" "}
|
|
540
|
+
{collection.singularLabel || collection.label || collectionSlug}
|
|
541
|
+
</button>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
) : (
|
|
545
|
+
<div className="overflow-x-auto">
|
|
546
|
+
<table className="w-full text-left">
|
|
547
|
+
<thead>
|
|
548
|
+
<tr className="text-[var(--kyro-text-secondary)] font-bold text-[10px] uppercase tracking-[0.3em] border-b border-[var(--kyro-border)]">
|
|
549
|
+
<th className="px-4 py-4 w-10">
|
|
550
|
+
<input
|
|
551
|
+
type="checkbox"
|
|
552
|
+
checked={
|
|
553
|
+
selectedIds.size === docs.length && docs.length > 0
|
|
554
|
+
}
|
|
555
|
+
onChange={handleSelectAll}
|
|
556
|
+
className="w-4 h-4 rounded border-[var(--kyro-border-strong)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
|
|
557
|
+
/>
|
|
558
|
+
</th>
|
|
559
|
+
{displayFields.map((field) => (
|
|
560
|
+
<th
|
|
561
|
+
key={field.name}
|
|
562
|
+
className="px-4 py-4 cursor-pointer hover:text-[var(--kyro-text-primary)] transition-colors"
|
|
563
|
+
onClick={() => handleSort(field.name)}
|
|
564
|
+
>
|
|
565
|
+
<div className="flex items-center gap-2">
|
|
566
|
+
{checkTabbedValue(displayFields, field.type) ?? (field.label || field.name)}
|
|
567
|
+
{sort?.field === field.name && (
|
|
568
|
+
<svg
|
|
569
|
+
className={`w-3 h-3 ${sort.direction === "desc" ? "rotate-180" : ""}`}
|
|
570
|
+
fill="none"
|
|
571
|
+
stroke="currentColor"
|
|
572
|
+
viewBox="0 0 24 24"
|
|
573
|
+
>
|
|
574
|
+
<path
|
|
575
|
+
strokeLinecap="round"
|
|
576
|
+
strokeLinejoin="round"
|
|
577
|
+
strokeWidth="2"
|
|
578
|
+
d="M5 15l7-7 7 7"
|
|
579
|
+
/>
|
|
580
|
+
</svg>
|
|
581
|
+
)}
|
|
582
|
+
</div>
|
|
583
|
+
</th>
|
|
584
|
+
))}
|
|
585
|
+
{collection.timestamps && (
|
|
586
|
+
<th className="px-4 py-4">Created</th>
|
|
587
|
+
)}
|
|
588
|
+
<th className="px-4 py-4 text-right">Actions</th>
|
|
589
|
+
</tr>
|
|
590
|
+
</thead>
|
|
591
|
+
<tbody className="divide-y divide-[var(--kyro-border)]">
|
|
592
|
+
{docs.map((doc) => (
|
|
593
|
+
<tr
|
|
594
|
+
key={doc.slug}
|
|
595
|
+
className="hover:bg-[var(--kyro-surface-accent)] transition-colors cursor-pointer group"
|
|
596
|
+
onClick={() => onEdit(doc.id)}
|
|
597
|
+
>
|
|
598
|
+
<td
|
|
599
|
+
className="px-4 py-3"
|
|
600
|
+
onClick={(e) => e.stopPropagation()}
|
|
601
|
+
>
|
|
602
|
+
<input
|
|
603
|
+
type="checkbox"
|
|
604
|
+
checked={selectedIds.has(doc.id)}
|
|
605
|
+
onChange={() => handleSelectOne(doc.id)}
|
|
606
|
+
className="w-4 h-4 rounded border-[var(--kyro-border-strong)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
|
|
607
|
+
/>
|
|
608
|
+
</td>
|
|
609
|
+
{displayFields.map((field, i) => (
|
|
610
|
+
<td
|
|
611
|
+
key={field.name}
|
|
612
|
+
className={`px-4 py-3 ${i === 0 ? "font-bold text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
|
|
613
|
+
>
|
|
614
|
+
{field.type === "select" && doc[field.name]
|
|
615
|
+
? field.options?.find(
|
|
616
|
+
(o) => o.value === doc[field.name],
|
|
617
|
+
)?.label || (doc[field.name])
|
|
618
|
+
: formatCellValue(doc[field.name], field.type)}
|
|
619
|
+
</td>
|
|
620
|
+
))}
|
|
621
|
+
{collection.timestamps && (
|
|
622
|
+
<td className="px-4 py-3 text-sm text-[var(--kyro-text-secondary)]">
|
|
623
|
+
{doc.createdAt
|
|
624
|
+
? new Date(doc.createdAt).toLocaleDateString(
|
|
625
|
+
"en-US",
|
|
626
|
+
{
|
|
627
|
+
month: "short",
|
|
628
|
+
day: "numeric",
|
|
629
|
+
year: "numeric",
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
: "—"}
|
|
633
|
+
</td>
|
|
634
|
+
)}
|
|
635
|
+
<td
|
|
636
|
+
className="px-4 py-3 text-right"
|
|
637
|
+
onClick={(e) => e.stopPropagation()}
|
|
638
|
+
>
|
|
639
|
+
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
640
|
+
<button type="button"
|
|
641
|
+
onClick={() => onEdit(doc.slug || doc.id)}
|
|
642
|
+
className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg text-sm font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-all"
|
|
643
|
+
title="Edit"
|
|
644
|
+
>
|
|
645
|
+
<svg
|
|
646
|
+
className="w-4 h-4"
|
|
647
|
+
fill="none"
|
|
648
|
+
stroke="currentColor"
|
|
649
|
+
viewBox="0 0 24 24"
|
|
650
|
+
>
|
|
651
|
+
<path
|
|
652
|
+
strokeLinecap="round"
|
|
653
|
+
strokeLinejoin="round"
|
|
654
|
+
strokeWidth="2"
|
|
655
|
+
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
656
|
+
/>
|
|
657
|
+
</svg>
|
|
658
|
+
</button>
|
|
659
|
+
<button type="button"
|
|
660
|
+
onClick={() => handleDeleteSingle(doc.id)}
|
|
661
|
+
className="inline-flex items-center justify-center w-8 h-8 rounded-md text-[var(--kyro-text-muted)] hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 transition-colors"
|
|
662
|
+
title="Delete"
|
|
663
|
+
>
|
|
664
|
+
<svg
|
|
665
|
+
className="w-4 h-4"
|
|
666
|
+
fill="none"
|
|
667
|
+
stroke="currentColor"
|
|
668
|
+
viewBox="0 0 24 24"
|
|
669
|
+
>
|
|
670
|
+
<path
|
|
671
|
+
strokeLinecap="round"
|
|
672
|
+
strokeLinejoin="round"
|
|
673
|
+
strokeWidth="2"
|
|
674
|
+
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"
|
|
675
|
+
/>
|
|
676
|
+
</svg>
|
|
677
|
+
</button>
|
|
678
|
+
</div>
|
|
679
|
+
</td>
|
|
680
|
+
</tr>
|
|
681
|
+
))}
|
|
682
|
+
</tbody>
|
|
683
|
+
</table>
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
|
|
688
|
+
{/* Pagination */}
|
|
689
|
+
{totalDocs > limit && (
|
|
690
|
+
<div className="flex flex-col lg:flex-row items-center justify-between gap-4 px-2">
|
|
691
|
+
<div className="flex items-center gap-4">
|
|
692
|
+
<span className="text-sm text-[var(--kyro-text-secondary)] font-medium">
|
|
693
|
+
Showing{" "}
|
|
694
|
+
<span className="text-[var(--kyro-text-primary)] font-bold">
|
|
695
|
+
{(page - 1) * limit + 1}
|
|
696
|
+
</span>{" "}
|
|
697
|
+
to{" "}
|
|
698
|
+
<span className="text-[var(--kyro-text-primary)] font-bold">
|
|
699
|
+
{Math.min(page * limit, totalDocs)}
|
|
700
|
+
</span>{" "}
|
|
701
|
+
of{" "}
|
|
702
|
+
<span className="text-[var(--kyro-text-primary)] font-bold">
|
|
703
|
+
{totalDocs}
|
|
704
|
+
</span>
|
|
705
|
+
</span>
|
|
706
|
+
<select
|
|
707
|
+
value={limit}
|
|
708
|
+
onChange={(e) => {
|
|
709
|
+
setLimit(Number(e.target.value));
|
|
710
|
+
setPage(1);
|
|
711
|
+
}}
|
|
712
|
+
className="px-2 py-1 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
|
|
713
|
+
>
|
|
714
|
+
<option value={10}>10 / page</option>
|
|
715
|
+
<option value={25}>25 / page</option>
|
|
716
|
+
<option value={50}>50 / page</option>
|
|
717
|
+
<option value={100}>100 / page</option>
|
|
718
|
+
</select>
|
|
719
|
+
</div>
|
|
720
|
+
<div className="flex gap-2">
|
|
721
|
+
{page > 1 && (
|
|
722
|
+
<button type="button"
|
|
723
|
+
onClick={() => setPage(page - 1)}
|
|
724
|
+
className="px-4 py-2 border border-[var(--kyro-border)] rounded-lg text-sm font-bold text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
|
|
725
|
+
>
|
|
726
|
+
← Previous
|
|
727
|
+
</button>
|
|
728
|
+
)}
|
|
729
|
+
{page < totalPages && (
|
|
730
|
+
<button type="button"
|
|
731
|
+
onClick={() => setPage(page + 1)}
|
|
732
|
+
className="px-4 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg text-sm font-bold hover:opacity-90 transition-all"
|
|
733
|
+
>
|
|
734
|
+
Next →
|
|
735
|
+
</button>
|
|
736
|
+
)}
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
async function handleDeleteSingle(id: string) {
|
|
744
|
+
setDeleteConfirm({ open: true, count: 1, ids: [id] });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const confirmDelete = async () => {
|
|
748
|
+
try {
|
|
749
|
+
for (const id of deleteConfirm.ids || []) {
|
|
750
|
+
await fetch(`/api/${collectionSlug}/${id}`, { method: "DELETE" });
|
|
751
|
+
}
|
|
752
|
+
fetchDocs();
|
|
753
|
+
} catch (error) {
|
|
754
|
+
console.error("Delete failed:", error);
|
|
755
|
+
}
|
|
756
|
+
setDeleteConfirm({ open: false, count: 0 });
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function formatCellValue(value: any, type?: string): string {
|
|
761
|
+
if (value === null || value === undefined) return "—";
|
|
762
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
763
|
+
if (type === "number" || type === "price") return String(value);
|
|
764
|
+
if (type === "date" || type === "datetime") {
|
|
765
|
+
return new Date(value).toLocaleDateString("en-US", {
|
|
766
|
+
month: "short",
|
|
767
|
+
day: "numeric",
|
|
768
|
+
year: "numeric",
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (typeof value === "object") {
|
|
773
|
+
if (value.title) return value.title;
|
|
774
|
+
if (value.name) return value.name;
|
|
775
|
+
if (value.email) return value.email;
|
|
776
|
+
return JSON.stringify(value).slice(0, 50);
|
|
777
|
+
}
|
|
778
|
+
return String(value).slice(0, 60);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
function checkTabbedValue(data: any[], type: string): string | undefined {
|
|
783
|
+
if (type !== "tabs") return;
|
|
784
|
+
const label = data[0]?.tabs[0]?.fields[0]?.label;
|
|
785
|
+
return label;
|
|
786
|
+
}
|