@kyro-cms/admin 0.3.2 → 0.3.5
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/dist/EditorClient-XEUOVAAC.js +466 -0
- package/dist/EditorClient-XEUOVAAC.js.map +1 -0
- package/dist/EditorClient-YLCGVDXY.cjs +468 -0
- package/dist/EditorClient-YLCGVDXY.cjs.map +1 -0
- package/dist/chunk-7KPIUCGT.js +384 -0
- package/dist/chunk-7KPIUCGT.js.map +1 -0
- package/dist/chunk-GOACG6R7.cjs +473 -0
- package/dist/chunk-GOACG6R7.cjs.map +1 -0
- package/dist/index.cjs +14861 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +1661 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +563 -0
- package/dist/index.js +14784 -0
- package/dist/index.js.map +1 -0
- package/package.json +19 -19
- package/src/components/ActionBar.tsx +7 -43
- package/src/components/Admin.tsx +138 -277
- package/src/components/ApiKeysManager.tsx +428 -419
- package/src/components/AuditLogsPage.tsx +35 -39
- package/src/components/AuthBridge.tsx +51 -0
- package/src/components/AutoForm.tsx +495 -1230
- package/src/components/BrandingHub.tsx +18 -19
- package/src/components/BulkActionsBar.tsx +1 -1
- package/src/components/CreateView.tsx +22 -36
- package/src/components/Dashboard.tsx +60 -84
- package/src/components/DetailView.tsx +113 -91
- package/src/components/DeveloperCenter.tsx +200 -198
- package/src/components/FieldRenderer.tsx +206 -0
- package/src/components/GraphQLPlayground.tsx +340 -480
- package/src/components/ListView.tsx +828 -254
- package/src/components/LoginPage.tsx +3 -4
- package/src/components/MarketplaceManager.tsx +254 -0
- package/src/components/MediaGallery.tsx +856 -1192
- package/src/components/PluginsManager.tsx +277 -0
- package/src/components/RestPlayground.tsx +398 -560
- package/src/components/SessionsManager.tsx +211 -0
- package/src/components/Sidebar.astro +179 -151
- package/src/components/ThemeProvider.tsx +7 -161
- package/src/components/UserManagement.tsx +162 -146
- package/src/components/UserMenu.tsx +110 -0
- package/src/components/WebhookManager.tsx +305 -367
- package/src/components/blocks/AccordionBlock.tsx +4 -4
- package/src/components/blocks/ArrayBlock.tsx +3 -3
- package/src/components/blocks/BlockEditModal.tsx +8 -8
- package/src/components/blocks/BlockWrapper.tsx +61 -0
- package/src/components/blocks/ButtonBlock.tsx +4 -4
- package/src/components/blocks/ChildBlocksTree.tsx +23 -25
- package/src/components/blocks/CodeBlock.tsx +15 -15
- package/src/components/blocks/ColumnsBlock.tsx +6 -44
- package/src/components/blocks/DividerBlock.tsx +3 -3
- package/src/components/blocks/FileBlock.tsx +4 -4
- package/src/components/blocks/HeadingBlock.tsx +6 -38
- package/src/components/blocks/HeroBlock.tsx +4 -4
- package/src/components/blocks/ImageBlock.tsx +4 -4
- package/src/components/blocks/LinkBlock.tsx +4 -4
- package/src/components/blocks/ListBlock.tsx +3 -3
- package/src/components/blocks/ParagraphBlock.tsx +12 -42
- package/src/components/blocks/RelationshipBlock.tsx +4 -4
- package/src/components/blocks/RichTextBlock.tsx +4 -4
- package/src/components/blocks/VStackBlock.tsx +5 -37
- package/src/components/blocks/VideoBlock.tsx +4 -4
- package/src/components/blocks/types.ts +11 -0
- package/src/components/fields/AccordionField.tsx +1 -1
- package/src/components/fields/ArrayField.tsx +2 -2
- package/src/components/fields/ArrayLayout.tsx +93 -0
- package/src/components/fields/BlocksField.tsx +122 -111
- package/src/components/fields/ButtonField.tsx +1 -1
- package/src/components/fields/CheckboxField.tsx +14 -15
- package/src/components/fields/ChildrenField.tsx +2 -2
- package/src/components/fields/CodeField.tsx +3 -3
- package/src/components/fields/ColumnsField.tsx +2 -2
- package/src/components/fields/DateField.tsx +13 -26
- package/src/components/fields/EditorClient.tsx +26 -28
- package/src/components/fields/FieldLayout.tsx +52 -0
- package/src/components/fields/GroupLayout.tsx +35 -0
- package/src/components/fields/JSONField.tsx +7 -7
- package/src/components/fields/LinkField.tsx +1 -1
- package/src/components/fields/MarkdownField.tsx +1 -1
- package/src/components/fields/NumberField.tsx +13 -26
- package/src/components/fields/PortableTextField.tsx +4 -4
- package/src/components/fields/PortableTextRenderer.tsx +1 -1
- package/src/components/fields/RelationshipBlockField.tsx +31 -23
- package/src/components/fields/RelationshipField.tsx +14 -14
- package/src/components/fields/SelectField.tsx +17 -26
- package/src/components/fields/TabsLayout.tsx +69 -0
- package/src/components/fields/TextField.tsx +85 -38
- package/src/components/fields/UploadField.tsx +71 -41
- package/src/components/fields/VideoField.tsx +1 -1
- package/src/components/fields/extensions/blockComponents.tsx +2 -2
- package/src/components/fields/extensions/blocksStore.ts +207 -193
- package/src/components/fields/types.ts +22 -0
- package/src/components/layout/Layout.tsx +1 -1
- package/src/components/ui/ActionMenu.tsx +63 -0
- package/src/components/ui/Badge.tsx +59 -5
- package/src/components/ui/BlockDrawer.tsx +4 -5
- package/src/components/ui/CommandPalette.tsx +58 -36
- package/src/components/ui/CommandPaletteWrapper.tsx +18 -17
- package/src/components/ui/Dropdown.tsx +18 -16
- package/src/components/ui/EmptyState.tsx +25 -0
- package/src/components/ui/GlobalModal.tsx +49 -0
- package/src/components/ui/IconButton.tsx +44 -0
- package/src/components/ui/Modal.tsx +19 -20
- package/src/components/ui/PageHeader.tsx +158 -0
- package/src/components/ui/Pagination.tsx +61 -0
- package/src/components/ui/PromptModal.tsx +1 -1
- package/src/components/ui/SearchInput.tsx +57 -0
- package/src/components/ui/SeoPreview.tsx +31 -0
- package/src/components/ui/SessionModal.tsx +0 -0
- package/src/components/ui/SlidePanel.tsx +2 -0
- package/src/components/ui/Toast.tsx +65 -122
- package/src/components/ui/Toaster.tsx +18 -0
- package/src/components/ui/icons.tsx +112 -0
- package/src/components/users/UserDetail.tsx +290 -0
- package/src/components/users/UserForm.tsx +242 -0
- package/src/components/users/UsersList.tsx +338 -0
- package/src/env.d.ts +13 -13
- package/src/fields/index.ts +2 -1
- package/src/global.d.ts +7 -0
- package/src/hooks/data.ts +2 -9
- package/src/hooks/useAsyncData.ts +36 -0
- package/src/hooks/useAutoFormState.ts +527 -0
- package/src/hooks/useSelection.ts +49 -0
- package/src/hooks/useSession.ts +0 -0
- package/src/index.ts +11 -1
- package/src/integration.ts +86 -11
- package/src/kyro-cms.d.ts +209 -0
- package/src/layouts/AdminLayout.astro +128 -11
- package/src/layouts/AuthLayout.astro +21 -5
- package/src/lib/api.ts +175 -55
- package/src/lib/autoform-store.ts +435 -0
- package/src/lib/config.ts +82 -34
- package/src/lib/createRegistry.ts +29 -0
- package/src/lib/default-kyro-config.ts +4 -0
- package/src/lib/globals.ts +50 -0
- package/src/lib/media-utils.ts +18 -0
- package/src/lib/object-utils.ts +77 -0
- package/src/lib/paths.ts +61 -0
- package/src/lib/stores/index.ts +370 -0
- package/src/lib/types.ts +43 -0
- package/src/lib/useResourceManager.ts +105 -0
- package/src/pages/403.astro +67 -0
- package/src/pages/[collection]/[id].astro +14 -180
- package/src/pages/[collection]/index.astro +11 -6
- package/src/pages/api-explorer.astro +173 -0
- package/src/pages/audit/index.astro +2 -0
- package/src/pages/auth/login.astro +122 -0
- package/src/pages/auth/register.astro +167 -0
- package/src/pages/graphql-explorer.astro +59 -0
- package/src/pages/{admin/graphql.astro → graphql.astro} +51 -17
- package/src/pages/index.astro +577 -0
- package/src/pages/index_ALT.astro +3 -0
- package/src/pages/keys.astro +11 -0
- package/src/pages/marketplace.astro +11 -0
- package/src/pages/media.astro +3 -0
- package/src/pages/plugins.astro +8 -0
- package/src/pages/preview/[collection]/[id].astro +188 -123
- package/src/pages/rest-playground.astro +62 -0
- package/src/pages/roles/index.astro +183 -76
- package/src/pages/sessions.astro +8 -0
- package/src/pages/settings/[slug].astro +92 -114
- package/src/pages/settings/index.astro +5 -3
- package/src/pages/users/[id].astro +25 -154
- package/src/pages/users/index.astro +19 -130
- package/src/pages/users/new.astro +9 -86
- package/src/pages/webhooks.astro +11 -0
- package/src/routes.ts +80 -0
- package/src/styles/main.css +119 -79
- package/src/theme/tokens.ts +1 -0
- package/src/vite-env.d.ts +14 -0
- package/src/collections/auth/index.ts +0 -155
- package/src/collections/portfolio/index.ts +0 -343
- package/src/components/ApiExplorer.tsx +0 -325
- package/src/components/EnhancedListView.tsx +0 -889
- package/src/components/GraphQLExplorer.tsx +0 -675
- package/src/components/Icons.tsx +0 -23
- package/src/components/StatusBadge.tsx +0 -76
- package/src/lib/MediaService.ts +0 -541
- package/src/lib/auth/sqlite-adapter.ts +0 -319
- package/src/lib/dataStore.ts +0 -226
- package/src/lib/db/adapter.ts +0 -54
- package/src/lib/db/drizzle-mysql-adapter.ts +0 -194
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +0 -327
- package/src/lib/db/drizzle-postgres-adapter.ts +0 -202
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +0 -304
- package/src/lib/db/drizzle-sqlite-adapter.ts +0 -227
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +0 -548
- package/src/lib/db/index.ts +0 -449
- package/src/lib/db/mongodb-adapter.ts +0 -207
- package/src/lib/db/mongodb-auth-adapter.ts +0 -305
- package/src/lib/db/schema/mysql-auth.ts +0 -113
- package/src/lib/db/schema/mysql-content.ts +0 -20
- package/src/lib/db/schema/postgres-auth.ts +0 -116
- package/src/lib/db/schema/postgres-content.ts +0 -35
- package/src/lib/db/schema/postgres-media.ts +0 -52
- package/src/lib/db/schema/postgres-settings.ts +0 -11
- package/src/lib/db/schema/sqlite-auth.ts +0 -112
- package/src/lib/db/schema/sqlite-content.ts +0 -20
- package/src/lib/db/version-adapter.ts +0 -248
- package/src/lib/graphql/index.ts +0 -1
- package/src/lib/graphql/schema.ts +0 -443
- package/src/lib/rate-limit.ts +0 -267
- package/src/lib/storage.ts +0 -374
- package/src/lib/store.ts +0 -85
- package/src/middleware.ts +0 -177
- package/src/pages/admin/api-explorer.astro +0 -98
- package/src/pages/admin/graphql-explorer.astro +0 -40
- package/src/pages/admin/index.astro +0 -286
- package/src/pages/admin/keys.astro +0 -8
- package/src/pages/admin/rest-playground.astro +0 -44
- package/src/pages/admin/webhooks.astro +0 -8
- package/src/pages/api/[collection]/[id]/publish.ts +0 -52
- package/src/pages/api/[collection]/[id]/unpublish.ts +0 -42
- package/src/pages/api/[collection]/[id]/versions.ts +0 -66
- package/src/pages/api/[collection]/[id].ts +0 -213
- package/src/pages/api/[collection]/index.ts +0 -209
- package/src/pages/api/auth/[id].ts +0 -121
- package/src/pages/api/auth/audit-logs.ts +0 -57
- package/src/pages/api/auth/login.ts +0 -211
- package/src/pages/api/auth/logout.ts +0 -66
- package/src/pages/api/auth/me.ts +0 -36
- package/src/pages/api/auth/refresh.ts +0 -119
- package/src/pages/api/auth/register.ts +0 -188
- package/src/pages/api/auth/users.ts +0 -97
- package/src/pages/api/collections.ts +0 -59
- package/src/pages/api/globals/[slug].ts +0 -42
- package/src/pages/api/graphql.ts +0 -90
- package/src/pages/api/health.ts +0 -426
- package/src/pages/api/keys/[id].ts +0 -26
- package/src/pages/api/keys/index.ts +0 -75
- package/src/pages/api/media/[id].ts +0 -309
- package/src/pages/api/media/folders.ts +0 -609
- package/src/pages/api/media/index.ts +0 -146
- package/src/pages/api/media/resize.ts +0 -267
- package/src/pages/api/search.ts +0 -82
- package/src/pages/api/slug-availability.ts +0 -70
- package/src/pages/api/storage-config.ts +0 -20
- package/src/pages/api/storage-status.ts +0 -206
- package/src/pages/api/upload.ts +0 -334
- package/src/pages/api/webhooks/index.ts +0 -71
- package/src/pages/login.astro +0 -82
- package/src/pages/register.astro +0 -102
|
@@ -1,332 +1,906 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
2
|
-
import { apiGet, apiDelete } from "../lib/api";
|
|
3
|
-
import type { CollectionConfig, KyroConfig } from "@kyro-cms/core/client";
|
|
1
|
+
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
4
2
|
import { Spinner } from "./ui/Spinner";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { Plus } from "./ui/icons";
|
|
4
|
+
import { apiGet, apiDelete, withCacheBust } from "../lib/api";
|
|
5
|
+
|
|
6
|
+
import { useAuthStore } from "../lib/stores";
|
|
7
|
+
import { useUIStore } from "../lib/stores";
|
|
8
|
+
import { adminPath as ADMIN_BASE } from "../lib/paths";
|
|
9
|
+
import { PageHeader } from "./ui/PageHeader";
|
|
10
|
+
import { Badge } from "./ui/Badge";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import type { CollectionConfig, Field } from "@kyro-cms/core";
|
|
14
|
+
|
|
15
|
+
type FieldConfig = Field;
|
|
16
|
+
|
|
17
|
+
interface FilterConfig {
|
|
18
|
+
field: string;
|
|
19
|
+
operator:
|
|
20
|
+
| "equals"
|
|
21
|
+
| "contains"
|
|
22
|
+
| "gt"
|
|
23
|
+
| "lt"
|
|
24
|
+
| "gte"
|
|
25
|
+
| "lte"
|
|
26
|
+
| "between"
|
|
27
|
+
| "in";
|
|
28
|
+
value: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SortConfig {
|
|
32
|
+
field: string;
|
|
33
|
+
direction: "asc" | "desc";
|
|
34
|
+
}
|
|
7
35
|
|
|
8
36
|
interface ListViewProps {
|
|
9
|
-
config: KyroConfig;
|
|
10
37
|
collection: CollectionConfig;
|
|
11
|
-
|
|
12
|
-
|
|
38
|
+
collectionSlug?: string;
|
|
39
|
+
initialDocs?: any[];
|
|
40
|
+
initialTotal?: number;
|
|
41
|
+
onCreate?: () => void;
|
|
42
|
+
onEdit?: (id: string) => void;
|
|
43
|
+
// For legacy Admin.tsx compatibility
|
|
44
|
+
config?: any;
|
|
13
45
|
}
|
|
14
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Unified ListView component used across both SPA (Admin.tsx) and MPA (Astro pages) modes.
|
|
49
|
+
*/
|
|
15
50
|
export function ListView({
|
|
16
|
-
config,
|
|
17
51
|
collection,
|
|
18
|
-
|
|
19
|
-
|
|
52
|
+
collectionSlug: providedSlug,
|
|
53
|
+
initialDocs = [],
|
|
54
|
+
initialTotal = 0,
|
|
55
|
+
onCreate: providedOnCreate,
|
|
56
|
+
onEdit: providedOnEdit,
|
|
57
|
+
config,
|
|
20
58
|
}: ListViewProps) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
59
|
+
const collectionSlug = providedSlug || collection.slug;
|
|
60
|
+
const { permissions } = useAuthStore();
|
|
61
|
+
const canCreate = permissions?.collections?.[collectionSlug]?.create !== false;
|
|
62
|
+
const canDelete = permissions?.collections?.[collectionSlug]?.delete !== false;
|
|
63
|
+
const canUpdate = permissions?.collections?.[collectionSlug]?.update !== false;
|
|
64
|
+
|
|
65
|
+
const handleCreate = () => {
|
|
66
|
+
if (!canCreate) return;
|
|
67
|
+
if (providedOnCreate) {
|
|
68
|
+
providedOnCreate();
|
|
69
|
+
} else {
|
|
70
|
+
window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleEdit = (id: string) => {
|
|
75
|
+
if (providedOnEdit) {
|
|
76
|
+
providedOnEdit(id);
|
|
77
|
+
} else {
|
|
78
|
+
window.location.href = `${ADMIN_BASE}/${collectionSlug}/${id}`;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const [docs, setDocs] = useState<any[]>(initialDocs);
|
|
83
|
+
const [totalDocs, setTotalDocs] = useState(initialTotal);
|
|
84
|
+
const [loading, setLoading] = useState(false);
|
|
23
85
|
const [page, setPage] = useState(1);
|
|
24
|
-
const [
|
|
25
|
-
const [limit] = useState(25);
|
|
26
|
-
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
27
|
-
const [searchQuery, setSearchQuery] = useState("");
|
|
86
|
+
const [limit, setLimit] = useState(10);
|
|
28
87
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
88
|
+
const [search, setSearch] = useState("");
|
|
89
|
+
const [filters, setFilters] = useState<FilterConfig[]>([]);
|
|
90
|
+
const { confirm, alert } = useUIStore();
|
|
29
91
|
|
|
30
|
-
const
|
|
92
|
+
const addFilter = () => {
|
|
93
|
+
setFilters([...filters, { field: "", operator: "equals", value: "" }]);
|
|
94
|
+
};
|
|
31
95
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
96
|
+
const clearAll = () => {
|
|
97
|
+
setSearch("");
|
|
98
|
+
setFilters([]);
|
|
99
|
+
setSort(null);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const removeFilter = (index: number) => {
|
|
103
|
+
setFilters(filters.filter((_, i) => i !== index));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const updateFilter = (index: number, updates: Partial<FilterConfig>) => {
|
|
107
|
+
setFilters(filters.map((f, i) => (i === index ? { ...f, ...updates } : f)));
|
|
108
|
+
};
|
|
109
|
+
const [sort, setSort] = useState<SortConfig | null>(null);
|
|
110
|
+
const [showFilters, setShowFilters] = useState(false);
|
|
111
|
+
const [showColumns, setShowColumns] = useState(false);
|
|
112
|
+
|
|
113
|
+
function flattenFields(fields: FieldConfig[]): FieldConfig[] {
|
|
114
|
+
const result: FieldConfig[] = [];
|
|
115
|
+
for (const field of fields || []) {
|
|
116
|
+
if (!field.name || field.admin?.hidden || field.name === "id") continue;
|
|
117
|
+
if (field.type === "tabs" && field.tabs) {
|
|
118
|
+
for (const tab of field.tabs) {
|
|
119
|
+
if (tab.fields) {
|
|
120
|
+
result.push(...flattenFields(tab.fields));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} else if (
|
|
124
|
+
(field.type === "row" || field.type === "collapsible") &&
|
|
125
|
+
field.fields
|
|
126
|
+
) {
|
|
127
|
+
result.push(...flattenFields(field.fields));
|
|
128
|
+
} else {
|
|
129
|
+
result.push(field);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const allFields = useMemo(
|
|
136
|
+
() => flattenFields(collection.fields),
|
|
137
|
+
[collection.fields],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const titleField: string | undefined =
|
|
141
|
+
typeof collection.admin?.useAsTitle === "string"
|
|
142
|
+
? collection.admin.useAsTitle
|
|
143
|
+
: allFields.find((f) => f.type !== "group" && typeof f.name === "string")?.name;
|
|
144
|
+
|
|
145
|
+
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
|
|
146
|
+
let cols: string[];
|
|
147
|
+
if (collection.admin?.defaultColumns) {
|
|
148
|
+
cols = [...(collection.admin?.defaultColumns as string[] || [])];
|
|
149
|
+
} else {
|
|
150
|
+
cols = allFields.slice(0, 4).map((f) => f.name).filter((n): n is string => !!n);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (titleField && cols.includes(titleField)) {
|
|
154
|
+
cols = [titleField, ...cols.filter((c) => c !== titleField)];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!cols.includes("updatedAt")) {
|
|
158
|
+
cols.push("updatedAt");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return new Set(cols);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const toggleColumn = useCallback((fieldName: string) => {
|
|
165
|
+
setVisibleColumns((prev) => {
|
|
166
|
+
const next = new Set(prev);
|
|
167
|
+
if (next.has(fieldName)) {
|
|
168
|
+
next.delete(fieldName);
|
|
169
|
+
} else {
|
|
170
|
+
next.add(fieldName);
|
|
171
|
+
}
|
|
172
|
+
return next;
|
|
173
|
+
});
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
function resolveSortField(fieldName: string): string {
|
|
177
|
+
const field = allFields.find((f) => f.name === fieldName);
|
|
178
|
+
if (!field) return fieldName;
|
|
179
|
+
if (field.type === "group" && field.fields?.[0]?.name) {
|
|
180
|
+
return `${fieldName}.${field.fields[0].name}`;
|
|
181
|
+
}
|
|
182
|
+
return fieldName;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handleSort = useCallback((fieldName: string) => {
|
|
186
|
+
const resolvedField = resolveSortField(fieldName);
|
|
187
|
+
setSort((prev) => {
|
|
188
|
+
if (prev && prev.field === resolvedField) {
|
|
189
|
+
return {
|
|
190
|
+
field: resolvedField,
|
|
191
|
+
direction: prev.direction === "asc" ? "desc" : "asc",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { field: resolvedField, direction: "asc" };
|
|
195
|
+
});
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
const displayFields = useMemo(
|
|
199
|
+
() => allFields.filter((f): f is typeof f & { name: string } => !!f.name && visibleColumns.has(f.name)),
|
|
200
|
+
[allFields, visibleColumns],
|
|
201
|
+
);
|
|
35
202
|
|
|
36
|
-
|
|
203
|
+
function fieldContainsTitle(field: FieldConfig): boolean {
|
|
204
|
+
if (!field.name || !titleField) return false;
|
|
205
|
+
if (field.name === titleField) return true;
|
|
206
|
+
if (field.type === "group" && field.fields?.[0]?.name === titleField)
|
|
207
|
+
return true;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function extractFieldValue(doc: any, field: FieldConfig): any {
|
|
212
|
+
if (!field.name) return null;
|
|
213
|
+
if (doc[field.name] !== undefined && doc[field.name] !== null) {
|
|
214
|
+
return doc[field.name];
|
|
215
|
+
}
|
|
216
|
+
if (field.type === "group" && typeof doc[field.name] === "object") {
|
|
217
|
+
const firstFieldName = field.fields?.[0]?.name;
|
|
218
|
+
if (
|
|
219
|
+
firstFieldName &&
|
|
220
|
+
doc[field.name][firstFieldName] !== undefined
|
|
221
|
+
) {
|
|
222
|
+
return doc[field.name][firstFieldName];
|
|
223
|
+
}
|
|
224
|
+
const firstKey = Object.keys(doc[field.name] || {})[0];
|
|
225
|
+
if (firstKey) return doc[field.name][firstKey];
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fetchDocs = useCallback(async () => {
|
|
231
|
+
setLoading(true);
|
|
37
232
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
233
|
+
const params = new URLSearchParams({
|
|
234
|
+
page: page.toString(),
|
|
235
|
+
limit: limit.toString(),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (search) params.append("search", search);
|
|
239
|
+
if (sort) params.append("sort", sort.field);
|
|
240
|
+
if (sort) params.append("order", sort.direction);
|
|
241
|
+
if (filters.length > 0) {
|
|
242
|
+
params.append("filters", JSON.stringify(filters));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const result = (await apiGet(
|
|
246
|
+
withCacheBust(`/api/${collectionSlug}?${params}`),
|
|
247
|
+
{ autoToast: false },
|
|
248
|
+
) as { docs?: Record<string, unknown>[]; totalDocs?: number });
|
|
42
249
|
setDocs(result.docs || []);
|
|
43
|
-
|
|
250
|
+
setTotalDocs(result.totalDocs || 0);
|
|
44
251
|
} catch (error) {
|
|
45
252
|
console.error("Failed to load docs:", error);
|
|
46
253
|
} finally {
|
|
47
254
|
setLoading(false);
|
|
48
255
|
}
|
|
49
|
-
};
|
|
256
|
+
}, [collectionSlug, page, limit, search, sort, filters]);
|
|
50
257
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
258
|
+
// Initial fetch only if not provided with initialDocs or if empty
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (docs.length === 0 && initialTotal === 0) {
|
|
261
|
+
fetchDocs();
|
|
262
|
+
}
|
|
263
|
+
}, []);
|
|
264
|
+
|
|
265
|
+
// Subsequent fetches on filter/pagination changes
|
|
266
|
+
const isFirstRender = useRef(true);
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (isFirstRender.current) {
|
|
269
|
+
isFirstRender.current = false;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
fetchDocs();
|
|
273
|
+
}, [page, limit, search, sort, filters]);
|
|
56
274
|
|
|
57
|
-
const
|
|
58
|
-
|
|
275
|
+
const handleSelectAll = () => {
|
|
276
|
+
if (selectedIds.size === docs.length) {
|
|
277
|
+
setSelectedIds(new Set());
|
|
278
|
+
} else {
|
|
279
|
+
setSelectedIds(new Set(docs.map((d) => d.id)));
|
|
280
|
+
}
|
|
59
281
|
};
|
|
60
282
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
console.error("Failed to delete:", error);
|
|
283
|
+
const handleSelectOne = (id: string) => {
|
|
284
|
+
const newSet = new Set(selectedIds);
|
|
285
|
+
if (newSet.has(id)) {
|
|
286
|
+
newSet.delete(id);
|
|
287
|
+
} else {
|
|
288
|
+
newSet.add(id);
|
|
68
289
|
}
|
|
69
|
-
|
|
290
|
+
setSelectedIds(newSet);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const handleBulkDelete = () => {
|
|
294
|
+
confirm({
|
|
295
|
+
title: "Delete Documents",
|
|
296
|
+
message: `Are you sure you want to delete ${selectedIds.size} document(s)? This cannot be undone.`,
|
|
297
|
+
variant: "danger",
|
|
298
|
+
onConfirm: async () => {
|
|
299
|
+
try {
|
|
300
|
+
for (const id of Array.from(selectedIds)) {
|
|
301
|
+
await apiDelete(`/api/${collectionSlug}/${id}`);
|
|
302
|
+
}
|
|
303
|
+
setSelectedIds(new Set());
|
|
304
|
+
fetchDocs();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error("Bulk delete failed:", error);
|
|
307
|
+
alert({ title: "Error", message: "Failed to delete some documents" });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const handleDeleteSingle = (id: string) => {
|
|
314
|
+
confirm({
|
|
315
|
+
title: "Delete Document",
|
|
316
|
+
message: "Are you sure you want to delete this document? This cannot be undone.",
|
|
317
|
+
variant: "danger",
|
|
318
|
+
onConfirm: async () => {
|
|
319
|
+
try {
|
|
320
|
+
await apiDelete(`/api/${collectionSlug}/${id}`);
|
|
321
|
+
fetchDocs();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error("Delete failed:", error);
|
|
324
|
+
alert({ title: "Error", message: "Failed to delete document" });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
70
328
|
};
|
|
71
329
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
Object.keys(collection.fields || {}).slice(0, 4);
|
|
330
|
+
const totalPages = Math.ceil(totalDocs / limit);
|
|
331
|
+
const hasActiveFilters = search || filters.length > 0 || sort;
|
|
75
332
|
|
|
76
333
|
return (
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
334
|
+
<div className="space-y-6">
|
|
335
|
+
<PageHeader
|
|
336
|
+
title={collection.label || collectionSlug}
|
|
337
|
+
description={collection.admin?.description || `Manage your ${collection.label || collectionSlug}`}
|
|
338
|
+
metadata={totalDocs > 0 ? [
|
|
339
|
+
<span key="count" className="text-xs font-bold opacity-60">
|
|
340
|
+
{totalDocs} documents
|
|
341
|
+
</span>
|
|
342
|
+
] : undefined}
|
|
343
|
+
action={canCreate ? {
|
|
344
|
+
label: `Create ${collection.singularLabel || collection.label || collectionSlug}`,
|
|
345
|
+
onClick: handleCreate,
|
|
346
|
+
icon: Plus,
|
|
347
|
+
} : undefined}
|
|
348
|
+
|
|
349
|
+
/>
|
|
87
350
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
351
|
+
|
|
352
|
+
{/* Toolbar */}
|
|
353
|
+
<div className="surface-tile p-4 flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
|
354
|
+
{/* Search */}
|
|
355
|
+
<div className="relative flex-1 max-w-md">
|
|
356
|
+
<svg
|
|
357
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-secondary)]"
|
|
358
|
+
fill="none"
|
|
359
|
+
stroke="currentColor"
|
|
360
|
+
viewBox="0 0 24 24"
|
|
361
|
+
>
|
|
362
|
+
<path
|
|
363
|
+
strokeLinecap="round"
|
|
364
|
+
strokeLinejoin="round"
|
|
365
|
+
strokeWidth="2"
|
|
366
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
97
367
|
/>
|
|
98
|
-
</
|
|
368
|
+
</svg>
|
|
369
|
+
<input
|
|
370
|
+
type="text"
|
|
371
|
+
placeholder="Search..."
|
|
372
|
+
value={search}
|
|
373
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
374
|
+
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"
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
379
|
+
{/* Filter Toggle */}
|
|
99
380
|
<button
|
|
100
381
|
type="button"
|
|
101
|
-
|
|
102
|
-
|
|
382
|
+
onClick={() => setShowFilters(!showFilters)}
|
|
383
|
+
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-sm transition-all ${showFilters || filters.length > 0
|
|
384
|
+
? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
|
|
385
|
+
: "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
386
|
+
}`}
|
|
103
387
|
>
|
|
104
|
-
<Plus className="w-4 h-4" />
|
|
105
|
-
Create New
|
|
106
|
-
</button>
|
|
107
|
-
</div>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
{loading ? (
|
|
111
|
-
<div className="kyro-loading">
|
|
112
|
-
<Spinner />
|
|
113
|
-
</div>
|
|
114
|
-
) : docs.length === 0 ? (
|
|
115
|
-
<div className="kyro-card">
|
|
116
|
-
<div className="kyro-empty">
|
|
117
388
|
<svg
|
|
118
|
-
className="
|
|
119
|
-
width="40"
|
|
120
|
-
height="40"
|
|
121
|
-
viewBox="0 0 24 24"
|
|
389
|
+
className="w-4 h-4"
|
|
122
390
|
fill="none"
|
|
123
391
|
stroke="currentColor"
|
|
124
|
-
|
|
392
|
+
viewBox="0 0 24 24"
|
|
125
393
|
>
|
|
126
|
-
<path
|
|
127
|
-
|
|
394
|
+
<path
|
|
395
|
+
strokeLinecap="round"
|
|
396
|
+
strokeLinejoin="round"
|
|
397
|
+
strokeWidth="2"
|
|
398
|
+
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"
|
|
399
|
+
/>
|
|
128
400
|
</svg>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
401
|
+
Filters
|
|
402
|
+
{filters.length > 0 && (
|
|
403
|
+
<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">
|
|
404
|
+
{filters.length}
|
|
405
|
+
</span>
|
|
406
|
+
)}
|
|
407
|
+
</button>
|
|
408
|
+
|
|
409
|
+
{/* Column Toggle */}
|
|
410
|
+
<div className="relative">
|
|
133
411
|
<button
|
|
134
412
|
type="button"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
onClick={onCreate}
|
|
413
|
+
onClick={() => setShowColumns(!showColumns)}
|
|
414
|
+
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"
|
|
138
415
|
>
|
|
139
|
-
|
|
416
|
+
<svg
|
|
417
|
+
className="w-4 h-4"
|
|
418
|
+
fill="none"
|
|
419
|
+
stroke="currentColor"
|
|
420
|
+
viewBox="0 0 24 24"
|
|
421
|
+
>
|
|
422
|
+
<path
|
|
423
|
+
strokeLinecap="round"
|
|
424
|
+
strokeLinejoin="round"
|
|
425
|
+
strokeWidth="2"
|
|
426
|
+
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"
|
|
427
|
+
/>
|
|
428
|
+
</svg>
|
|
429
|
+
Columns
|
|
140
430
|
</button>
|
|
431
|
+
{showColumns && (
|
|
432
|
+
<div className="absolute right-0 top-full mt-2 w-56 surface-tile border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 overflow-hidden">
|
|
433
|
+
<div className="p-3 border-b border-[var(--kyro-border)]">
|
|
434
|
+
<span className="text-xs font-bold tracking-wider text-[var(--kyro-text-secondary)]">
|
|
435
|
+
Toggle Columns
|
|
436
|
+
</span>
|
|
437
|
+
</div>
|
|
438
|
+
<div className="p-2 max-h-64 overflow-y-auto">
|
|
439
|
+
{allFields.map((field) => {
|
|
440
|
+
if (!field.name) return null;
|
|
441
|
+
return (
|
|
442
|
+
<label
|
|
443
|
+
key={field.name}
|
|
444
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--kyro-surface-accent)] cursor-pointer"
|
|
445
|
+
>
|
|
446
|
+
<input
|
|
447
|
+
type="checkbox"
|
|
448
|
+
checked={visibleColumns.has(field.name)}
|
|
449
|
+
onChange={() => toggleColumn(field.name!)}
|
|
450
|
+
className="w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
|
|
451
|
+
/>
|
|
452
|
+
<span className="text-sm font-medium text-[var(--kyro-text-primary)]">
|
|
453
|
+
{field.label || field.name}
|
|
454
|
+
</span>
|
|
455
|
+
</label>
|
|
456
|
+
);
|
|
457
|
+
})}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
141
461
|
</div>
|
|
462
|
+
|
|
463
|
+
{/* Clear All */}
|
|
464
|
+
{hasActiveFilters && (
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
onClick={clearAll}
|
|
468
|
+
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"
|
|
469
|
+
>
|
|
470
|
+
Clear All
|
|
471
|
+
</button>
|
|
472
|
+
)}
|
|
142
473
|
</div>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
{/* Filter Panel */}
|
|
477
|
+
{showFilters && (
|
|
478
|
+
<div className="surface-tile p-4 border-l-4 border-[var(--kyro-sidebar-active)]">
|
|
479
|
+
<div className="flex items-center justify-between mb-4">
|
|
480
|
+
<h3 className="font-medium text-[var(--kyro-text-primary)]">
|
|
481
|
+
Advanced Filters
|
|
482
|
+
</h3>
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={addFilter}
|
|
486
|
+
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"
|
|
487
|
+
>
|
|
488
|
+
<svg
|
|
489
|
+
className="w-4 h-4"
|
|
490
|
+
fill="none"
|
|
491
|
+
stroke="currentColor"
|
|
492
|
+
viewBox="0 0 24 24"
|
|
493
|
+
>
|
|
494
|
+
<path
|
|
495
|
+
strokeLinecap="round"
|
|
496
|
+
strokeLinejoin="round"
|
|
497
|
+
strokeWidth="2"
|
|
498
|
+
d="M12 5v14M5 12h14"
|
|
499
|
+
/>
|
|
500
|
+
</svg>
|
|
501
|
+
Add Filter
|
|
502
|
+
</button>
|
|
503
|
+
</div>
|
|
504
|
+
<div className="space-y-3">
|
|
505
|
+
{filters.map((filter, index) => (
|
|
506
|
+
<div key={index} className="flex flex-wrap gap-2 items-center">
|
|
507
|
+
<select
|
|
508
|
+
value={filter.field}
|
|
509
|
+
onChange={(e) =>
|
|
510
|
+
updateFilter(index, { field: e.target.value })
|
|
511
|
+
}
|
|
512
|
+
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)]"
|
|
513
|
+
>
|
|
514
|
+
{allFields.map((field) => (
|
|
515
|
+
<option key={field.name} value={field.name}>
|
|
516
|
+
{field.label || field.name}
|
|
517
|
+
</option>
|
|
166
518
|
))}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
key={doc.id}
|
|
177
|
-
className={`group hover:bg-[var(--kyro-primary)] transition-all cursor-pointer ${selectedIds.has(doc.id) ? "bg-[var(--kyro-primary)]" : ""}`}
|
|
178
|
-
onClick={() => onEdit(doc.id)}
|
|
519
|
+
</select>
|
|
520
|
+
<select
|
|
521
|
+
value={filter.operator}
|
|
522
|
+
onChange={(e) =>
|
|
523
|
+
updateFilter(index, {
|
|
524
|
+
operator: e.target.value as FilterConfig["operator"],
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
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)]"
|
|
179
528
|
>
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
529
|
+
<option value="equals">Equals</option>
|
|
530
|
+
<option value="contains">Contains</option>
|
|
531
|
+
<option value="gt">Greater than</option>
|
|
532
|
+
<option value="lt">Less than</option>
|
|
533
|
+
<option value="gte">Greater or equal</option>
|
|
534
|
+
<option value="lte">Less or equal</option>
|
|
535
|
+
</select>
|
|
536
|
+
<input
|
|
537
|
+
type="text"
|
|
538
|
+
value={filter.value}
|
|
539
|
+
onChange={(e) =>
|
|
540
|
+
updateFilter(index, { value: e.target.value })
|
|
541
|
+
}
|
|
542
|
+
placeholder="Value..."
|
|
543
|
+
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)]"
|
|
544
|
+
/>
|
|
545
|
+
<button
|
|
546
|
+
type="button"
|
|
547
|
+
onClick={() => removeFilter(index)}
|
|
548
|
+
className="p-2 text-[var(--kyro-text-muted)] hover:text-red-500 transition-colors"
|
|
549
|
+
>
|
|
550
|
+
<svg
|
|
551
|
+
className="w-4 h-4"
|
|
552
|
+
fill="none"
|
|
553
|
+
stroke="currentColor"
|
|
554
|
+
viewBox="0 0 24 24"
|
|
183
555
|
>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (next.has(doc.id)) next.delete(doc.id);
|
|
190
|
-
else next.add(doc.id);
|
|
191
|
-
setSelectedIds(next);
|
|
192
|
-
}}
|
|
193
|
-
className="accent-[var(--kyro-primary)]"
|
|
556
|
+
<path
|
|
557
|
+
strokeLinecap="round"
|
|
558
|
+
strokeLinejoin="round"
|
|
559
|
+
strokeWidth="2"
|
|
560
|
+
d="M6 18L18 6M6 6l12 12"
|
|
194
561
|
/>
|
|
195
|
-
</
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
</td>
|
|
206
|
-
{columns
|
|
207
|
-
.filter((c) => c !== "title" && c !== "name")
|
|
208
|
-
.map((col) => (
|
|
209
|
-
<td
|
|
210
|
-
key={col}
|
|
211
|
-
className="px-6 py-5 text-sm font-medium text-[var(--kyro-text-secondary)]"
|
|
212
|
-
>
|
|
213
|
-
{formatValue(doc[col])}
|
|
214
|
-
</td>
|
|
215
|
-
))}
|
|
216
|
-
<td className="px-6 py-5">
|
|
217
|
-
<span
|
|
218
|
-
className={`inline-flex items-center px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest ${doc.status === "published" ? "bg-green-500/10 text-green-500" : "bg-amber-500/10 text-amber-500"}`}
|
|
219
|
-
>
|
|
220
|
-
{doc.status || "draft"}
|
|
221
|
-
</span>
|
|
222
|
-
</td>
|
|
223
|
-
<td
|
|
224
|
-
className="px-6 py-5 text-right"
|
|
225
|
-
onClick={(e) => e.stopPropagation()}
|
|
226
|
-
>
|
|
227
|
-
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
228
|
-
<button
|
|
229
|
-
type="button"
|
|
230
|
-
className="p-2 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-lg transition-all"
|
|
231
|
-
onClick={() => onEdit(doc.id)}
|
|
232
|
-
title="Edit"
|
|
233
|
-
>
|
|
234
|
-
<Settings className="w-4 h-4" />
|
|
235
|
-
</button>
|
|
236
|
-
<button
|
|
237
|
-
type="button"
|
|
238
|
-
className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-all"
|
|
239
|
-
onClick={() => handleDelete(doc.id)}
|
|
240
|
-
title="Delete"
|
|
241
|
-
>
|
|
242
|
-
<svg
|
|
243
|
-
width="16"
|
|
244
|
-
height="16"
|
|
245
|
-
viewBox="0 0 24 24"
|
|
246
|
-
fill="none"
|
|
247
|
-
stroke="currentColor"
|
|
248
|
-
strokeWidth="2"
|
|
249
|
-
>
|
|
250
|
-
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
251
|
-
</svg>
|
|
252
|
-
</button>
|
|
253
|
-
</div>
|
|
254
|
-
</td>
|
|
255
|
-
</tr>
|
|
256
|
-
))}
|
|
257
|
-
</tbody>
|
|
258
|
-
</table>
|
|
562
|
+
</svg>
|
|
563
|
+
</button>
|
|
564
|
+
</div>
|
|
565
|
+
))}
|
|
566
|
+
{filters.length === 0 && (
|
|
567
|
+
<p className="text-sm text-[var(--kyro-text-muted)]">
|
|
568
|
+
No filters applied. Click "Add Filter" to create one.
|
|
569
|
+
</p>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
259
572
|
</div>
|
|
260
573
|
)}
|
|
261
574
|
|
|
575
|
+
{/* Bulk Actions */}
|
|
262
576
|
{selectedIds.size > 0 && (
|
|
263
|
-
<div className="
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
<p className="text-xs font-black uppercase tracking-widest">
|
|
270
|
-
Docs Selected
|
|
271
|
-
</p>
|
|
577
|
+
<div className="surface-tile p-4 flex items-center justify-between border-l-4 border-[var(--kyro-sidebar-active)]">
|
|
578
|
+
<span className="text-sm font-medium text-[var(--kyro-text-primary)]">
|
|
579
|
+
{selectedIds.size} selected
|
|
580
|
+
</span>
|
|
581
|
+
<div className="flex gap-2">
|
|
582
|
+
{canDelete && (
|
|
272
583
|
<button
|
|
273
584
|
type="button"
|
|
274
|
-
onClick={
|
|
275
|
-
className="text-
|
|
585
|
+
onClick={handleBulkDelete}
|
|
586
|
+
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"
|
|
276
587
|
>
|
|
277
|
-
|
|
588
|
+
<svg
|
|
589
|
+
className="w-4 h-4"
|
|
590
|
+
fill="none"
|
|
591
|
+
stroke="currentColor"
|
|
592
|
+
viewBox="0 0 24 24"
|
|
593
|
+
>
|
|
594
|
+
<path
|
|
595
|
+
strokeLinecap="round"
|
|
596
|
+
strokeLinejoin="round"
|
|
597
|
+
strokeWidth="2"
|
|
598
|
+
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"
|
|
599
|
+
/>
|
|
600
|
+
</svg>
|
|
601
|
+
Delete Selected
|
|
278
602
|
</button>
|
|
279
|
-
|
|
280
|
-
</div>
|
|
281
|
-
<div className="flex items-center gap-3 pr-2">
|
|
603
|
+
)}
|
|
282
604
|
<button
|
|
283
605
|
type="button"
|
|
284
|
-
|
|
606
|
+
onClick={() => setSelectedIds(new Set())}
|
|
607
|
+
className="px-4 py-2 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] font-bold text-sm transition-all"
|
|
285
608
|
>
|
|
286
|
-
|
|
609
|
+
Cancel
|
|
287
610
|
</button>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
)}
|
|
614
|
+
|
|
615
|
+
{/* Data Table */}
|
|
616
|
+
<div className="surface-tile overflow-hidden">
|
|
617
|
+
{loading ? (
|
|
618
|
+
<div className="flex items-center justify-center py-20">
|
|
619
|
+
<Spinner />
|
|
620
|
+
</div>
|
|
621
|
+
) : docs.length === 0 ? (
|
|
622
|
+
<div className="flex flex-col items-center justify-center py-16 px-8">
|
|
623
|
+
<div className="w-16 h-16 rounded-2xl bg-[var(--kyro-surface-accent)] flex items-center justify-center mb-4">
|
|
624
|
+
<svg
|
|
625
|
+
className="w-8 h-8 text-[var(--kyro-text-muted)]"
|
|
626
|
+
fill="none"
|
|
627
|
+
stroke="currentColor"
|
|
628
|
+
viewBox="0 0 24 24"
|
|
629
|
+
>
|
|
630
|
+
<path
|
|
631
|
+
strokeLinecap="round"
|
|
632
|
+
strokeLinejoin="round"
|
|
633
|
+
strokeWidth="1.5"
|
|
634
|
+
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"
|
|
635
|
+
/>
|
|
636
|
+
</svg>
|
|
637
|
+
</div>
|
|
638
|
+
<p className="font-medium text-[var(--kyro-text-primary)] text-base">
|
|
639
|
+
No documents found
|
|
640
|
+
</p>
|
|
641
|
+
<p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
|
|
642
|
+
{hasActiveFilters
|
|
643
|
+
? "Try adjusting your filters or search query."
|
|
644
|
+
: `Get started by creating your first ${((collection.singularLabel || collection.label || collectionSlug) as string).toLowerCase()}.`}
|
|
645
|
+
</p>
|
|
646
|
+
{!hasActiveFilters && canCreate && (
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
onClick={handleCreate}
|
|
650
|
+
className="mt-4 kyro-btn kyro-btn-md kyro-btn-primary shadow-md flex items-center gap-2"
|
|
651
|
+
>
|
|
652
|
+
<svg
|
|
653
|
+
className="w-3.5 h-3.5"
|
|
654
|
+
fill="none"
|
|
655
|
+
stroke="currentColor"
|
|
656
|
+
viewBox="0 0 24 24"
|
|
657
|
+
>
|
|
658
|
+
<path
|
|
659
|
+
strokeLinecap="round"
|
|
660
|
+
strokeLinejoin="round"
|
|
661
|
+
strokeWidth="2.5"
|
|
662
|
+
d="M12 5v14M5 12h14"
|
|
663
|
+
/>
|
|
664
|
+
</svg>
|
|
665
|
+
Create{" "}
|
|
666
|
+
{String(collection.singularLabel || collection.label || collectionSlug)}
|
|
667
|
+
</button>
|
|
668
|
+
)}
|
|
669
|
+
</div>
|
|
670
|
+
) : (
|
|
671
|
+
<div className="overflow-x-auto">
|
|
672
|
+
<table className="w-full text-left">
|
|
673
|
+
<thead>
|
|
674
|
+
<tr className="text-[var(--kyro-text-secondary)] font-bold text-[10px] tracking-[0.3em] border-b border-[var(--kyro-border)]">
|
|
675
|
+
<th className="px-4 py-4 w-10">
|
|
676
|
+
<input
|
|
677
|
+
type="checkbox"
|
|
678
|
+
checked={
|
|
679
|
+
selectedIds.size === docs.length && docs.length > 0
|
|
680
|
+
}
|
|
681
|
+
onChange={handleSelectAll}
|
|
682
|
+
className="w-4 h-4 rounded border-[var(--kyro-border-strong)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
|
|
683
|
+
/>
|
|
684
|
+
</th>
|
|
685
|
+
{displayFields.map((field) => (
|
|
686
|
+
<th
|
|
687
|
+
key={field.name}
|
|
688
|
+
className="px-4 py-4 cursor-pointer hover:text-[var(--kyro-text-primary)] transition-colors"
|
|
689
|
+
onClick={() => handleSort(field.name)}
|
|
690
|
+
>
|
|
691
|
+
<div className="flex items-center gap-2">
|
|
692
|
+
{checkTabbedValue(displayFields, field.type) ??
|
|
693
|
+
(field.label || field.name)}
|
|
694
|
+
{sort && sort.field === field.name && (
|
|
695
|
+
<svg
|
|
696
|
+
className={`w-3 h-3 ${sort.direction === "desc" ? "rotate-180" : ""}`}
|
|
697
|
+
fill="none"
|
|
698
|
+
stroke="currentColor"
|
|
699
|
+
viewBox="0 0 24 24"
|
|
700
|
+
>
|
|
701
|
+
<path
|
|
702
|
+
strokeLinecap="round"
|
|
703
|
+
strokeLinejoin="round"
|
|
704
|
+
strokeWidth="2"
|
|
705
|
+
d="M5 15l7-7 7 7"
|
|
706
|
+
/>
|
|
707
|
+
</svg>
|
|
708
|
+
)}
|
|
709
|
+
</div>
|
|
710
|
+
</th>
|
|
711
|
+
))}
|
|
712
|
+
{collection.timestamps ? (
|
|
713
|
+
<th className="px-4 py-4">Created</th>
|
|
714
|
+
) : null}
|
|
715
|
+
<th className="px-4 py-4 text-right">Actions</th>
|
|
716
|
+
</tr>
|
|
717
|
+
</thead>
|
|
718
|
+
<tbody className="divide-y divide-[var(--kyro-border)]">
|
|
719
|
+
{docs.map((doc) => (
|
|
720
|
+
<tr
|
|
721
|
+
key={doc.id}
|
|
722
|
+
className="hover:bg-[var(--kyro-surface-accent)] transition-colors cursor-pointer group"
|
|
723
|
+
onClick={() => handleEdit(doc.id)}
|
|
724
|
+
>
|
|
725
|
+
<td
|
|
726
|
+
className="px-4 py-3"
|
|
727
|
+
onClick={(e) => e.stopPropagation()}
|
|
728
|
+
>
|
|
729
|
+
<input
|
|
730
|
+
type="checkbox"
|
|
731
|
+
checked={selectedIds.has(doc.id)}
|
|
732
|
+
onChange={() => handleSelectOne(doc.id)}
|
|
733
|
+
className="w-4 h-4 rounded border-[var(--kyro-border-strong)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
|
|
734
|
+
/>
|
|
735
|
+
</td>
|
|
736
|
+
{displayFields.map((field) => {
|
|
737
|
+
const rawValue = extractFieldValue(doc, field);
|
|
738
|
+
const cellValue =
|
|
739
|
+
field.type === "select" && rawValue
|
|
740
|
+
? field.options?.find((o: any) => o.value === rawValue)
|
|
741
|
+
?.label || rawValue
|
|
742
|
+
: formatCellValue(rawValue, field.type);
|
|
743
|
+
return (
|
|
744
|
+
<td
|
|
745
|
+
key={field.name}
|
|
746
|
+
className={`px-4 py-3 ${fieldContainsTitle(field) ? "font-medium text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
|
|
747
|
+
>
|
|
748
|
+
{cellValue}
|
|
749
|
+
</td>
|
|
750
|
+
);
|
|
751
|
+
})}
|
|
752
|
+
{collection.timestamps ? (
|
|
753
|
+
<td className="px-4 py-3 text-sm text-[var(--kyro-text-secondary)]">
|
|
754
|
+
{doc.createdAt
|
|
755
|
+
? new Date(doc.createdAt as string).toLocaleDateString(
|
|
756
|
+
"en-US",
|
|
757
|
+
{
|
|
758
|
+
month: "short",
|
|
759
|
+
day: "numeric",
|
|
760
|
+
year: "numeric",
|
|
761
|
+
},
|
|
762
|
+
)
|
|
763
|
+
: "—"}
|
|
764
|
+
</td>
|
|
765
|
+
) : null}
|
|
766
|
+
<td
|
|
767
|
+
className="px-4 py-3 text-right"
|
|
768
|
+
onClick={(e) => e.stopPropagation()}
|
|
769
|
+
>
|
|
770
|
+
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
771
|
+
<button
|
|
772
|
+
type="button"
|
|
773
|
+
onClick={() => handleEdit(doc.id)}
|
|
774
|
+
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"
|
|
775
|
+
title={canUpdate ? "Edit" : "View"}
|
|
776
|
+
>
|
|
777
|
+
<svg
|
|
778
|
+
className="w-4 h-4"
|
|
779
|
+
fill="none"
|
|
780
|
+
stroke="currentColor"
|
|
781
|
+
viewBox="0 0 24 24"
|
|
782
|
+
>
|
|
783
|
+
<path
|
|
784
|
+
strokeLinecap="round"
|
|
785
|
+
strokeLinejoin="round"
|
|
786
|
+
strokeWidth="2"
|
|
787
|
+
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"
|
|
788
|
+
/>
|
|
789
|
+
</svg>
|
|
790
|
+
</button>
|
|
791
|
+
{canDelete && (
|
|
792
|
+
<button
|
|
793
|
+
type="button"
|
|
794
|
+
onClick={() => handleDeleteSingle(doc.id)}
|
|
795
|
+
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"
|
|
796
|
+
title="Delete"
|
|
797
|
+
>
|
|
798
|
+
<svg
|
|
799
|
+
className="w-4 h-4"
|
|
800
|
+
fill="none"
|
|
801
|
+
stroke="currentColor"
|
|
802
|
+
viewBox="0 0 24 24"
|
|
803
|
+
>
|
|
804
|
+
<path
|
|
805
|
+
strokeLinecap="round"
|
|
806
|
+
strokeLinejoin="round"
|
|
807
|
+
strokeWidth="2"
|
|
808
|
+
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"
|
|
809
|
+
/>
|
|
810
|
+
</svg>
|
|
811
|
+
</button>
|
|
812
|
+
)}
|
|
813
|
+
</div>
|
|
814
|
+
</td>
|
|
815
|
+
</tr>
|
|
816
|
+
))}
|
|
817
|
+
</tbody>
|
|
818
|
+
</table>
|
|
819
|
+
</div>
|
|
820
|
+
)}
|
|
821
|
+
</div>
|
|
822
|
+
|
|
823
|
+
{/* Pagination */}
|
|
824
|
+
{totalDocs > limit && (
|
|
825
|
+
<div className="flex flex-col lg:flex-row items-center justify-between gap-4 px-2">
|
|
826
|
+
<div className="flex items-center gap-4">
|
|
827
|
+
<span className="text-sm text-[var(--kyro-text-secondary)] font-medium">
|
|
828
|
+
Showing{" "}
|
|
829
|
+
<span className="text-[var(--kyro-text-primary)] font-bold">
|
|
830
|
+
{(page - 1) * limit + 1}
|
|
831
|
+
</span>{" "}
|
|
832
|
+
to{" "}
|
|
833
|
+
<span className="text-[var(--kyro-text-primary)] font-bold">
|
|
834
|
+
{Math.min(page * limit, totalDocs)}
|
|
835
|
+
</span>{" "}
|
|
836
|
+
of{" "}
|
|
837
|
+
<span className="text-[var(--kyro-text-primary)] font-bold">
|
|
838
|
+
{totalDocs}
|
|
839
|
+
</span>
|
|
840
|
+
</span>
|
|
841
|
+
<select
|
|
842
|
+
value={limit}
|
|
843
|
+
onChange={(e) => {
|
|
844
|
+
setLimit(Number(e.target.value));
|
|
845
|
+
setPage(1);
|
|
302
846
|
}}
|
|
303
|
-
className="
|
|
847
|
+
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)]"
|
|
304
848
|
>
|
|
305
|
-
|
|
306
|
-
|
|
849
|
+
<option value={10}>10 / page</option>
|
|
850
|
+
<option value={25}>25 / page</option>
|
|
851
|
+
<option value={50}>50 / page</option>
|
|
852
|
+
<option value={100}>100 / page</option>
|
|
853
|
+
</select>
|
|
854
|
+
</div>
|
|
855
|
+
<div className="flex gap-2">
|
|
856
|
+
{page > 1 && (
|
|
857
|
+
<button
|
|
858
|
+
type="button"
|
|
859
|
+
onClick={() => setPage(page - 1)}
|
|
860
|
+
className="px-4 py-2 border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
|
|
861
|
+
>
|
|
862
|
+
← Previous
|
|
863
|
+
</button>
|
|
864
|
+
)}
|
|
865
|
+
{page < totalPages && (
|
|
866
|
+
<button
|
|
867
|
+
type="button"
|
|
868
|
+
onClick={() => setPage(page + 1)}
|
|
869
|
+
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"
|
|
870
|
+
>
|
|
871
|
+
Next →
|
|
872
|
+
</button>
|
|
873
|
+
)}
|
|
307
874
|
</div>
|
|
308
875
|
</div>
|
|
309
876
|
)}
|
|
310
|
-
|
|
311
|
-
open={!!deleteId}
|
|
312
|
-
onClose={() => setDeleteId(null)}
|
|
313
|
-
onConfirm={confirmDelete}
|
|
314
|
-
title="Delete Document"
|
|
315
|
-
message="Are you sure you want to delete this document? This cannot be undone."
|
|
316
|
-
confirmLabel="Delete"
|
|
317
|
-
variant="danger"
|
|
318
|
-
/>
|
|
319
|
-
</>
|
|
877
|
+
</div>
|
|
320
878
|
);
|
|
321
879
|
}
|
|
322
880
|
|
|
323
|
-
function
|
|
881
|
+
function formatCellValue(value: any, type?: string): string {
|
|
324
882
|
if (value === null || value === undefined) return "—";
|
|
325
883
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
884
|
+
if (type === "number" || type === "price") return String(value);
|
|
885
|
+
if (type === "date" || type === "datetime") {
|
|
886
|
+
return new Date(value).toLocaleDateString("en-US", {
|
|
887
|
+
month: "short",
|
|
888
|
+
day: "numeric",
|
|
889
|
+
year: "numeric",
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
326
893
|
if (typeof value === "object") {
|
|
327
894
|
if (value.title) return value.title;
|
|
328
895
|
if (value.name) return value.name;
|
|
896
|
+
if (value.email) return value.email;
|
|
329
897
|
return JSON.stringify(value).slice(0, 50);
|
|
330
898
|
}
|
|
331
|
-
return String(value).slice(0,
|
|
899
|
+
return String(value).slice(0, 60);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function checkTabbedValue(data: any[], type: string): string | undefined {
|
|
903
|
+
if (type !== "tabs") return;
|
|
904
|
+
const label = data[0]?.tabs?.[0]?.fields?.[0]?.label;
|
|
905
|
+
return label;
|
|
332
906
|
}
|