@kyro-cms/admin 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +149 -51
  2. package/package.json +54 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +137 -28
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +2155 -770
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +4 -4
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +200 -58
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +890 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +192 -54
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +206 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/ThemeProvider.tsx +8 -2
  26. package/src/components/UserManagement.tsx +204 -0
  27. package/src/components/VersionHistoryPanel.tsx +3 -3
  28. package/src/components/WebhookManager.tsx +608 -0
  29. package/src/components/blocks/AccordionBlock.tsx +65 -0
  30. package/src/components/blocks/ArrayBlock.tsx +84 -0
  31. package/src/components/blocks/BlockEditModal.tsx +363 -0
  32. package/src/components/blocks/ButtonBlock.tsx +64 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +114 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +93 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +63 -0
  38. package/src/components/blocks/HeadingBlock.tsx +59 -0
  39. package/src/components/blocks/HeroBlock.tsx +99 -0
  40. package/src/components/blocks/ImageBlock.tsx +82 -0
  41. package/src/components/blocks/LinkBlock.tsx +65 -0
  42. package/src/components/blocks/ListBlock.tsx +60 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +72 -0
  45. package/src/components/blocks/RichTextBlock.tsx +66 -0
  46. package/src/components/blocks/VStackBlock.tsx +61 -0
  47. package/src/components/blocks/VideoBlock.tsx +65 -0
  48. package/src/components/blocks/index.ts +10 -0
  49. package/src/components/fields/AccordionField.tsx +213 -0
  50. package/src/components/fields/ArrayField.tsx +241 -0
  51. package/src/components/fields/BlocksField.tsx +323 -0
  52. package/src/components/fields/ButtonField.tsx +53 -0
  53. package/src/components/fields/CheckboxField.tsx +18 -8
  54. package/src/components/fields/ChildrenField.tsx +48 -0
  55. package/src/components/fields/CodeField.tsx +294 -0
  56. package/src/components/fields/ColumnsField.tsx +137 -0
  57. package/src/components/fields/DateField.tsx +24 -12
  58. package/src/components/fields/EditorClient.tsx +537 -0
  59. package/src/components/fields/HeadingField.tsx +31 -0
  60. package/src/components/fields/HeroField.tsx +101 -0
  61. package/src/components/fields/JSONField.tsx +341 -0
  62. package/src/components/fields/LinkField.tsx +81 -0
  63. package/src/components/fields/ListField.tsx +74 -0
  64. package/src/components/fields/MarkdownField.tsx +260 -0
  65. package/src/components/fields/NumberField.tsx +25 -13
  66. package/src/components/fields/PortableTextField.tsx +155 -0
  67. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  68. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  69. package/src/components/fields/RelationshipField.tsx +278 -60
  70. package/src/components/fields/SelectField.tsx +28 -16
  71. package/src/components/fields/TextField.tsx +31 -15
  72. package/src/components/fields/UploadField.tsx +613 -0
  73. package/src/components/fields/VideoField.tsx +73 -0
  74. package/src/components/fields/extensions/blockComponents.tsx +247 -0
  75. package/src/components/fields/extensions/blocksStore.ts +273 -0
  76. package/src/components/fields/index.ts +24 -0
  77. package/src/components/index.ts +1 -2
  78. package/src/components/layout/Header.tsx +2 -2
  79. package/src/components/layout/Layout.tsx +3 -3
  80. package/src/components/ui/Badge.tsx +9 -4
  81. package/src/components/ui/BlockDrawer.tsx +79 -0
  82. package/src/components/ui/Button.tsx +1 -1
  83. package/src/components/ui/CommandPalette.tsx +362 -0
  84. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  85. package/src/components/ui/Dropdown.tsx +1 -1
  86. package/src/components/ui/Modal.tsx +37 -12
  87. package/src/components/ui/PromptModal.tsx +94 -0
  88. package/src/components/ui/SlidePanel.tsx +43 -16
  89. package/src/components/ui/Toast.tsx +80 -14
  90. package/src/env.d.ts +16 -0
  91. package/src/env.ts +20 -0
  92. package/src/index.ts +0 -1
  93. package/src/layouts/AdminLayout.astro +164 -170
  94. package/src/layouts/AuthLayout.astro +23 -6
  95. package/src/lib/MediaService.ts +541 -0
  96. package/src/lib/api.ts +163 -0
  97. package/src/lib/auth/sqlite-adapter.ts +319 -0
  98. package/src/lib/config.ts +23 -7
  99. package/src/lib/dataStore.ts +188 -73
  100. package/src/lib/date-utils.ts +69 -0
  101. package/src/lib/db/adapter.ts +54 -0
  102. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  103. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  104. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  105. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  106. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  107. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  108. package/src/lib/db/index.ts +449 -0
  109. package/src/lib/db/mongodb-adapter.ts +207 -0
  110. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  111. package/src/lib/db/schema/mysql-auth.ts +113 -0
  112. package/src/lib/db/schema/mysql-content.ts +20 -0
  113. package/src/lib/db/schema/postgres-auth.ts +116 -0
  114. package/src/lib/db/schema/postgres-content.ts +35 -0
  115. package/src/lib/db/schema/postgres-media.ts +52 -0
  116. package/src/lib/db/schema/postgres-settings.ts +11 -0
  117. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  118. package/src/lib/db/schema/sqlite-content.ts +20 -0
  119. package/src/lib/db/version-adapter.ts +248 -0
  120. package/src/lib/graphql/index.ts +1 -0
  121. package/src/lib/graphql/schema.ts +443 -0
  122. package/src/lib/i18n.tsx +353 -0
  123. package/src/lib/rate-limit.ts +267 -0
  124. package/src/lib/slugify.ts +15 -0
  125. package/src/lib/storage.ts +374 -0
  126. package/src/lib/store.ts +85 -0
  127. package/src/lib/validation.ts +250 -0
  128. package/src/middleware.ts +70 -11
  129. package/src/pages/[collection]/[id].astro +178 -122
  130. package/src/pages/[collection]/index.astro +24 -156
  131. package/src/pages/admin/api-explorer.astro +98 -0
  132. package/src/pages/admin/graphql-explorer.astro +40 -0
  133. package/src/pages/admin/graphql.astro +97 -0
  134. package/src/pages/admin/index.astro +200 -139
  135. package/src/pages/admin/keys.astro +8 -0
  136. package/src/pages/admin/rest-playground.astro +44 -0
  137. package/src/pages/admin/webhooks.astro +8 -0
  138. package/src/pages/api/[collection]/[id]/publish.ts +52 -0
  139. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  140. package/src/pages/api/[collection]/[id]/versions.ts +66 -0
  141. package/src/pages/api/[collection]/[id].ts +114 -159
  142. package/src/pages/api/[collection]/index.ts +150 -230
  143. package/src/pages/api/auth/[id].ts +48 -69
  144. package/src/pages/api/auth/audit-logs.ts +20 -43
  145. package/src/pages/api/auth/login.ts +159 -45
  146. package/src/pages/api/auth/logout.ts +42 -24
  147. package/src/pages/api/auth/refresh.ts +119 -0
  148. package/src/pages/api/auth/register.ts +110 -40
  149. package/src/pages/api/auth/users.ts +22 -97
  150. package/src/pages/api/collections.ts +59 -0
  151. package/src/pages/api/globals/[slug]/test.ts +172 -0
  152. package/src/pages/api/globals/[slug].ts +42 -0
  153. package/src/pages/api/graphql.ts +90 -0
  154. package/src/pages/api/health.ts +417 -40
  155. package/src/pages/api/keys/[id].ts +26 -0
  156. package/src/pages/api/keys/index.ts +75 -0
  157. package/src/pages/api/media/[id].ts +309 -0
  158. package/src/pages/api/media/folders.ts +609 -0
  159. package/src/pages/api/media/index.ts +146 -0
  160. package/src/pages/api/media/resize.ts +267 -0
  161. package/src/pages/api/search.ts +82 -0
  162. package/src/pages/api/slug-availability.ts +70 -0
  163. package/src/pages/api/storage-config.ts +20 -0
  164. package/src/pages/api/storage-status.ts +206 -0
  165. package/src/pages/api/upload.ts +334 -0
  166. package/src/pages/api/webhooks/index.ts +71 -0
  167. package/src/pages/audit/index.astro +2 -104
  168. package/src/pages/login.astro +11 -11
  169. package/src/pages/media.astro +10 -0
  170. package/src/pages/preview/[collection]/[id].astro +178 -0
  171. package/src/pages/register.astro +13 -13
  172. package/src/pages/roles/index.astro +21 -21
  173. package/src/pages/settings/[slug].astro +162 -0
  174. package/src/pages/settings/index.astro +9 -0
  175. package/src/pages/users/[id].astro +29 -21
  176. package/src/pages/users/index.astro +22 -17
  177. package/src/pages/users/new.astro +18 -17
  178. package/src/styles/main.css +563 -128
  179. package/src/components/layout/Sidebar.tsx +0 -497
package/src/middleware.ts CHANGED
@@ -9,15 +9,41 @@ const PUBLIC_PATHS = [
9
9
  "/api/auth/register",
10
10
  "/api/auth/me",
11
11
  "/api/auth/users",
12
+ "/api/auth/refresh",
13
+ "/api/users",
12
14
  "/api/health",
15
+ "/api/search",
16
+ "/api/upload",
17
+ "/api/media",
13
18
  "/login",
14
19
  "/register",
15
20
  "/favicon.svg",
16
21
  ];
17
22
 
18
- const PUBLIC_PREFIXES = ["/api/collections/", "/api/auth/"];
23
+ const PUBLIC_PREFIXES = [
24
+ "/api/collections/",
25
+ "/api/auth/",
26
+ "/api/globals/",
27
+ "/api/media/",
28
+ ];
29
+
30
+ const isApiRequest = (pathname: string): boolean => {
31
+ return pathname.startsWith("/api/");
32
+ };
19
33
 
20
- export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
34
+ const redirectToLogin = (): Response => {
35
+ return new Response(null, {
36
+ status: 302,
37
+ headers: {
38
+ Location: "/login",
39
+ },
40
+ });
41
+ };
42
+
43
+ export const onRequest: MiddlewareHandler = async (
44
+ { request, url, locals },
45
+ next,
46
+ ) => {
21
47
  const pathname = new URL(url).pathname;
22
48
 
23
49
  // Helper to extract token from cookie or header
@@ -35,6 +61,21 @@ export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
35
61
 
36
62
  const token = getToken();
37
63
 
64
+ // Set user in locals if token is valid
65
+ if (token) {
66
+ try {
67
+ const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload;
68
+ locals.user = {
69
+ id: payload.sub || "",
70
+ email: (payload as any).email || "",
71
+ role: (payload as any).role || "guest",
72
+ tenantId: (payload as any).tenantId,
73
+ };
74
+ } catch {
75
+ // Token invalid, leave user undefined
76
+ }
77
+ }
78
+
38
79
  // Handle root path - redirect to admin for authenticated users
39
80
  if (pathname === "/") {
40
81
  if (!token) {
@@ -100,19 +141,37 @@ export const onRequest: MiddlewareHandler = async ({ request, url }, next) => {
100
141
  }
101
142
 
102
143
  if (!token) {
103
- return new Response(JSON.stringify({ error: "Authentication required" }), {
104
- status: 401,
105
- headers: { "Content-Type": "application/json" },
106
- });
144
+ if (!isApiRequest(pathname)) {
145
+ return redirectToLogin();
146
+ }
147
+ return new Response(
148
+ JSON.stringify({ error: "Authentication required. Please log in." }),
149
+ {
150
+ status: 401,
151
+ headers: { "Content-Type": "application/json" },
152
+ },
153
+ );
107
154
  }
108
155
 
109
156
  try {
110
157
  jwt.verify(token, JWT_SECRET);
111
158
  return next();
112
- } catch {
113
- return new Response(JSON.stringify({ error: "Invalid or expired token" }), {
114
- status: 401,
115
- headers: { "Content-Type": "application/json" },
116
- });
159
+ } catch (err) {
160
+ const isExpired = err instanceof jwt.TokenExpiredError;
161
+ if (!isApiRequest(pathname)) {
162
+ return redirectToLogin();
163
+ }
164
+ return new Response(
165
+ JSON.stringify({
166
+ error: isExpired
167
+ ? "Token expired. Please refresh your session or log in again."
168
+ : "Invalid token. Please log in again.",
169
+ code: isExpired ? "TOKEN_EXPIRED" : "TOKEN_INVALID",
170
+ }),
171
+ {
172
+ status: 401,
173
+ headers: { "Content-Type": "application/json" },
174
+ },
175
+ );
117
176
  }
118
177
  };
@@ -1,176 +1,232 @@
1
1
  ---
2
- import AdminLayout from '../../layouts/AdminLayout.astro';
3
- import { collections } from '@/lib/config';
4
- import { AutoForm } from '@/components/AutoForm';
2
+ import AdminLayout from "../../layouts/AdminLayout.astro";
3
+ import { collections } from "@/lib/config";
4
+ import { AutoForm } from "@/components/AutoForm";
5
5
 
6
6
  const { collection, id } = Astro.params;
7
7
 
8
8
  // Validate collection exists
9
9
  if (!collection || !collections[collection]) {
10
- return Astro.redirect('/');
10
+ return Astro.redirect("/");
11
11
  }
12
12
 
13
13
  const config = collections[collection];
14
14
 
15
+ // Handle legacy integer IDs (e.g., "team-1" -> find by slug instead)
16
+ let lookupId = id;
17
+ if (id && id.includes("-")) {
18
+ const parts = id.split("-");
19
+ const potentialNum = parts[parts.length - 1];
20
+ if (/^\d+$/.test(potentialNum)) {
21
+ // Legacy integer ID - try to find document by slug from the remaining parts
22
+ const slugPart = parts.slice(0, -1).join("-");
23
+ try {
24
+ const slugResponse = await fetch(
25
+ `${Astro.url.origin}/api/${collection}?limit=100`,
26
+ {
27
+ headers: { "Content-Type": "application/json" },
28
+ },
29
+ );
30
+ if (slugResponse.ok) {
31
+ const slugResult = await slugResponse.json();
32
+ const found = (slugResult.docs || []).find(
33
+ (d: any) => d.slug === slugPart,
34
+ );
35
+ if (found) {
36
+ lookupId = found.id;
37
+ }
38
+ }
39
+ } catch (e) {}
40
+ }
41
+ }
42
+
15
43
  // Fetch document if editing
16
44
  let doc: any = null;
17
- if (id && id !== 'new') {
45
+ if (lookupId && lookupId !== "new") {
18
46
  try {
19
- const response = await fetch(`${Astro.url.origin}/api/${collection}/${id}`);
47
+ const response = await fetch(
48
+ `${Astro.url.origin}/api/${collection}/${lookupId}`,
49
+ {
50
+ headers: Astro.request.headers,
51
+ credentials: "include",
52
+ },
53
+ );
20
54
  if (response.ok) {
21
55
  const result = await response.json();
22
56
  doc = result.data || null;
23
57
  }
24
58
  } catch (error) {
25
- console.error('Failed to fetch document:', error);
59
+ console.error("Failed to fetch document:", error);
26
60
  }
27
61
  }
28
62
 
63
+ // Redirect to UUID URL if using legacy ID
64
+ if (id && lookupId && id !== lookupId && doc) {
65
+ return Astro.redirect(`/${collection}/${lookupId}`, 301);
66
+ }
67
+
29
68
  const isNew = !doc;
30
- const title = isNew ? `Create ${config.singularLabel || config.label || collection}` : `Edit ${config.singularLabel || config.label || collection}`;
31
- const description = config.admin?.description || `Manage ${(config.label || collection || '').toLowerCase()} documents`;
69
+ const docStatus = doc?.status || "draft";
70
+ const title = isNew
71
+ ? `Create ${config.singularLabel || config.label || collection}`
72
+ : `Edit ${config.singularLabel || config.label || collection}`;
73
+ const description =
74
+ config.admin?.description ||
75
+ `Manage ${(config.label || collection || "").toLowerCase()} documents`;
32
76
  ---
33
77
 
34
78
  <AdminLayout title={title}>
35
79
  <div class="flex-1 overflow-y-auto p-8 space-y-6">
36
- <!-- Header -->
37
- <div class="surface-tile p-8 flex items-center justify-between">
38
- <div class="flex items-center gap-4">
39
- <a
40
- href={`/${collection}`}
41
- class="inline-flex items-center justify-center w-10 h-10 rounded-xl border border-gray-200 text-[#64748b] hover:text-[#0b1222] hover:bg-gray-50 transition-colors"
42
- >
43
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
44
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"></path>
45
- </svg>
46
- </a>
47
- <div>
48
- <h1 class="text-2xl font-black tracking-tighter text-[#0b1222]">{title}</h1>
49
- <p class="text-sm text-[#64748b] mt-0.5">{description}</p>
50
- </div>
51
- </div>
52
- <div class="flex items-center gap-3">
53
- <a
54
- href={`/${collection}`}
55
- class="px-5 py-2.5 border border-gray-200 rounded-xl text-[#0b1222] font-bold text-sm hover:bg-gray-50 transition-colors"
56
- >
57
- Cancel
58
- </a>
59
- <button
60
- type="submit"
61
- form="doc-form"
62
- id="btn-save"
63
- class="px-6 py-2.5 bg-[#0b1222] text-white rounded-xl font-bold text-sm hover:bg-[#1a2332] transition-colors active:scale-95"
64
- >
65
- {isNew ? 'Create' : 'Save Changes'}
66
- </button>
67
- </div>
68
- </div>
69
-
70
- <!-- Form Card -->
71
- <div class="surface-tile p-8">
72
- <!-- Toast container -->
73
- <div id="toast-container" class="hidden fixed bottom-6 right-6 z-50">
74
- <div class="flex items-center gap-3 px-5 py-4 bg-[#0b1222] text-white rounded-xl shadow-2xl">
75
- <span id="toast-message"></span>
76
- <button onclick="document.getElementById('toast-container').classList.add('hidden')" class="text-white/50 hover:text-white ml-2">
77
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
78
- </button>
79
- </div>
80
- </div>
81
-
82
- <form id="doc-form">
83
- <AutoForm
84
- client:load
85
- config={config}
86
- data={doc || {}}
87
- collectionSlug={collection}
88
- />
89
- </form>
90
- </div>
91
-
92
- <!-- Document Metadata (edit mode only) -->
93
- {!isNew && doc && (
94
- <div class="surface-tile p-8">
95
- <h3 class="text-xs font-bold text-[#64748b] uppercase tracking-[0.2em] mb-4">Document Info</h3>
96
- <div class="grid grid-cols-2 md:grid-cols-4 gap-6">
97
- <div>
98
- <p class="text-[10px] font-bold text-[#9ca3af] uppercase tracking-widest mb-1">ID</p>
99
- <p class="text-sm text-[#0b1222] font-mono">{doc.id?.slice(0, 12)}…</p>
100
- </div>
101
- {doc.createdAt && (
102
- <div>
103
- <p class="text-[10px] font-bold text-[#9ca3af] uppercase tracking-widest mb-1">Created</p>
104
- <p class="text-sm text-[#0b1222]">{new Date(doc.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
105
- </div>
106
- )}
107
- {doc.updatedAt && (
108
- <div>
109
- <p class="text-[10px] font-bold text-[#9ca3af] uppercase tracking-widest mb-1">Updated</p>
110
- <p class="text-sm text-[#0b1222]">{new Date(doc.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
111
- </div>
112
- )}
113
- </div>
114
- </div>
115
- )}
80
+ <form id="doc-form">
81
+ <AutoForm
82
+ client:only="react"
83
+ config={config}
84
+ data={doc || {}}
85
+ collectionSlug={collection}
86
+ documentName={doc?.title || doc?.name || doc?.slug || "new-document"}
87
+ />
88
+ <input type="hidden" id="form-data" name="form-data" value="{}" />
89
+ </form>
116
90
  </div>
117
91
 
118
92
  <script define:vars={{ collection, id, isNew }}>
119
93
  function showToast(message, isError = false) {
120
- const container = document.getElementById('toast-container');
121
- const msg = document.getElementById('toast-message');
94
+ const container = document.getElementById("toast-container");
95
+ const msg = document.getElementById("toast-message");
122
96
  if (container && msg) {
123
97
  msg.textContent = message;
124
- container.classList.remove('hidden');
98
+ container.classList.remove("hidden");
125
99
  if (!isError) {
126
- setTimeout(() => container.classList.add('hidden'), 3000);
100
+ setTimeout(() => container.classList.add("hidden"), 3000);
127
101
  }
128
102
  }
129
103
  }
130
104
 
131
- document.getElementById('doc-form')?.addEventListener('submit', async (e) => {
132
- e.preventDefault();
133
-
134
- const btn = document.getElementById('btn-save');
135
- const originalText = btn?.textContent || '';
136
- if (btn) {
137
- btn.textContent = 'Saving…';
138
- btn.setAttribute('disabled', 'true');
105
+ // Wait for DOM to be ready
106
+ function setupFormHandler() {
107
+ const form = document.getElementById("doc-form");
108
+ const btn = document.getElementById("btn-save");
109
+
110
+ if (!form || !btn) {
111
+ setTimeout(setupFormHandler, 100);
112
+ return;
139
113
  }
140
114
 
141
- const formData = new FormData(e.target);
142
- const data = {};
143
- formData.forEach((value, key) => {
144
- data[key] = value;
115
+ // Button click handler
116
+ btn.addEventListener("click", async () => {
117
+ // Check form validity
118
+ if (!form.checkValidity()) {
119
+ form.reportValidity();
120
+ return;
121
+ }
122
+
123
+ const originalText = btn.textContent || "";
124
+ btn.textContent = "Saving…";
125
+ btn.setAttribute("disabled", "true");
126
+
127
+ // Get data from hidden input (populated by React AutoForm onChange)
128
+ const hiddenInput = document.getElementById("form-data");
129
+ let data = {};
130
+
131
+ if (hiddenInput && hiddenInput.value) {
132
+ try {
133
+ const val = hiddenInput.value;
134
+ if (val) data = JSON.parse(val);
135
+ } catch (err) {
136
+ console.error("Failed to parse form data:", err);
137
+ }
138
+ }
139
+
140
+ const url = isNew ? `/api/${collection}` : `/api/${collection}/${id}`;
141
+ const method = isNew ? "POST" : "PATCH";
142
+
143
+ try {
144
+ const response = await fetch(url, {
145
+ method,
146
+ credentials: "include",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify(data),
149
+ });
150
+
151
+ if (response.ok) {
152
+ showToast(
153
+ isNew ? "Document created successfully" : "Changes saved",
154
+ );
155
+ setTimeout(() => {
156
+ window.location.href = `/${collection}`;
157
+ }, 800);
158
+ } else {
159
+ const error = await response.json();
160
+ showToast(error.error || "An error occurred", true);
161
+ }
162
+ } catch (error) {
163
+ showToast("Failed to save document", true);
164
+ } finally {
165
+ btn.textContent = originalText;
166
+ btn.removeAttribute("disabled");
167
+ }
145
168
  });
169
+ }
170
+
171
+ setupFormHandler();
172
+
173
+ // Setup publish/unpublish handlers
174
+ const btnPublish = document.getElementById("btn-publish");
175
+ const btnUnpublish = document.getElementById("btn-unpublish");
146
176
 
147
- const url = isNew ? `/api/${collection}` : `/api/${collection}/${id}`;
148
- const method = isNew ? 'POST' : 'PATCH';
177
+ async function handlePublish() {
178
+ if (!btnPublish) return;
179
+ btnPublish.textContent = "Publishing...";
180
+ btnPublish.setAttribute("disabled", "true");
149
181
 
150
182
  try {
151
- const response = await fetch(url, {
152
- method,
153
- headers: { 'Content-Type': 'application/json' },
154
- body: JSON.stringify(data),
183
+ const response = await fetch(`/api/${collection}/${id}/publish`, {
184
+ method: "POST",
185
+ credentials: "include",
155
186
  });
156
187
 
157
188
  if (response.ok) {
158
- showToast(isNew ? 'Document created successfully' : 'Changes saved');
159
- setTimeout(() => {
160
- window.location.href = `/${collection}`;
161
- }, 800);
189
+ showToast("Published successfully");
190
+ location.reload();
162
191
  } else {
163
192
  const error = await response.json();
164
- showToast(error.error || 'An error occurred', true);
193
+ showToast(error.error || "Failed to publish", true);
165
194
  }
166
- } catch (error) {
167
- showToast('Failed to save document', true);
195
+ } catch (err) {
196
+ showToast("Failed to publish", true);
168
197
  } finally {
169
- if (btn) {
170
- btn.textContent = originalText;
171
- btn.removeAttribute('disabled');
198
+ btnPublish.textContent = "Publish";
199
+ btnPublish.removeAttribute("disabled");
200
+ }
201
+ }
202
+
203
+ async function handleUnpublish() {
204
+ if (!btnUnpublish) return;
205
+ btnUnpublish.textContent = "Unpublishing...";
206
+ btnUnpublish.setAttribute("disabled", "true");
207
+
208
+ try {
209
+ const response = await fetch(`/api/${collection}/${id}/unpublish`, {
210
+ method: "POST",
211
+ credentials: "include",
212
+ });
213
+
214
+ if (response.ok) {
215
+ showToast("Unpublished successfully");
216
+ location.reload();
217
+ } else {
218
+ const error = await response.json();
219
+ showToast(error.error || "Failed to unpublish", true);
172
220
  }
221
+ } catch (err) {
222
+ showToast("Failed to unpublish", true);
223
+ } finally {
224
+ btnUnpublish.textContent = "Unpublish";
225
+ btnUnpublish.removeAttribute("disabled");
173
226
  }
174
- });
227
+ }
228
+
229
+ if (btnPublish) btnPublish.addEventListener("click", handlePublish);
230
+ if (btnUnpublish) btnUnpublish.addEventListener("click", handleUnpublish);
175
231
  </script>
176
232
  </AdminLayout>
@@ -1,180 +1,48 @@
1
1
  ---
2
- import AdminLayout from '../../layouts/AdminLayout.astro';
3
- import { collections } from '@/lib/config';
2
+ import AdminLayout from "../../layouts/AdminLayout.astro";
3
+ import { collections } from "@/lib/config";
4
+ import { EnhancedListView } from "@/components/EnhancedListView";
4
5
 
5
6
  const { collection } = Astro.params;
6
7
 
7
- // Validate collection exists
8
8
  if (!collection || !collections[collection]) {
9
- return Astro.redirect('/');
9
+ return Astro.redirect("/");
10
10
  }
11
11
 
12
12
  const config = collections[collection];
13
- const visibleFields = config.fields.filter(f => f.name && !f.admin?.hidden && f.name !== 'id');
14
- const displayFields = visibleFields.slice(0, 4);
15
13
 
16
- // Fetch documents from API
14
+ const page = parseInt(Astro.url.searchParams.get("page") || "1");
15
+ const limit = parseInt(Astro.url.searchParams.get("limit") || "10");
16
+
17
17
  let docs: any[] = [];
18
18
  let totalDocs = 0;
19
- const page = parseInt(Astro.url.searchParams.get('page') || '1');
20
- const limit = parseInt(Astro.url.searchParams.get('limit') || '10');
21
19
 
22
20
  try {
23
- const response = await fetch(`${Astro.url.origin}/api/${collection}?page=${page}&limit=${limit}`);
21
+ const response = await fetch(
22
+ `${Astro.url.origin}/api/${collection}?page=${page}&limit=${limit}&t=${Date.now()}`,
23
+ {
24
+ headers: Astro.request.headers,
25
+ credentials: "include",
26
+ },
27
+ );
24
28
  if (response.ok) {
25
29
  const data = await response.json();
26
30
  docs = data.docs || [];
27
31
  totalDocs = data.totalDocs || 0;
28
32
  }
29
33
  } catch (error) {
30
- console.error('Failed to fetch documents:', error);
34
+ console.error("Failed to fetch documents:", error);
31
35
  }
32
-
33
- const totalPages = Math.ceil(totalDocs / limit);
34
- const collectionDescription = config.admin?.description || `Manage your ${config.label || collection} collection`;
35
36
  ---
36
37
 
37
- <AdminLayout title={config.label || collection || 'Collection'}>
38
- <div class="flex-1 overflow-y-auto p-8 space-y-6">
39
- <!-- Header -->
40
- <div class="surface-tile p-8 flex items-center justify-between">
41
- <div>
42
- <h1 class="text-3xl font-black tracking-tighter text-[#0b1222]">
43
- {config.label || collection}
44
- </h1>
45
- <p class="text-sm text-[#64748b] mt-1 font-medium">
46
- {collectionDescription}
47
- {totalDocs > 0 && <span class="ml-2 text-[#0b1222] font-bold">· {totalDocs} documents</span>}
48
- </p>
49
- </div>
50
- <a
51
- href={`/${collection}/new`}
52
- id="btn-create-new"
53
- class="flex items-center gap-2 px-6 py-3 bg-[#0b1222] text-white rounded-xl font-bold transition-all hover:bg-[#1a2332] active:scale-95"
54
- >
55
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"></path>
57
- </svg>
58
- Create {config.singularLabel || config.label || collection}
59
- </a>
60
- </div>
61
-
62
- <!-- Data Table -->
63
- <div class="surface-tile overflow-hidden p-0">
64
- <table class="w-full text-left">
65
- <thead>
66
- <tr class="text-[#64748b] font-bold text-[10px] uppercase tracking-[0.3em] border-b border-gray-100">
67
- <th class="px-8 py-6 w-8">
68
- <div class="w-5 h-5 rounded-md border-2 border-gray-200"></div>
69
- </th>
70
- {displayFields.map(field => (
71
- <th class="px-6 py-6">{field.label || field.name}</th>
72
- ))}
73
- {config.timestamps && (
74
- <th class="px-6 py-6">Created</th>
75
- )}
76
- <th class="px-6 py-6 text-right">Actions</th>
77
- </tr>
78
- </thead>
79
- <tbody class="divide-y divide-gray-50">
80
- {docs.length === 0 ? (
81
- <tr>
82
- <td colspan="100%" class="px-8 py-16 text-center">
83
- <div class="flex flex-col items-center gap-4">
84
- <div class="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center">
85
- <svg class="w-8 h-8 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"></path>
87
- </svg>
88
- </div>
89
- <div>
90
- <p class="font-bold text-[#0b1222] text-base">No documents yet</p>
91
- <p class="text-sm text-[#64748b] mt-1">
92
- Get started by creating your first {(config.singularLabel || config.label || collection || 'item').toLowerCase()}.
93
- </p>
94
- </div>
95
- <a
96
- href={`/${collection}/new`}
97
- class="mt-2 inline-flex items-center gap-2 px-5 py-2.5 bg-[#0b1222] text-white rounded-lg font-bold text-sm"
98
- >
99
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
100
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"></path>
101
- </svg>
102
- Create {config.singularLabel || config.label || collection}
103
- </a>
104
- </div>
105
- </td>
106
- </tr>
107
- ) : (
108
- docs.map((doc) => (
109
- <tr class="group hover:bg-gray-50/50 transition-colors cursor-pointer" onclick={`window.location='/${collection}/${doc.id}'`}>
110
- <td class="px-8 py-5">
111
- <div class="w-5 h-5 rounded-md border-2 border-gray-200 group-hover:border-[#0b1222] transition-colors"></div>
112
- </td>
113
- {displayFields.map((field, i) => (
114
- <td class={`px-6 py-5 ${i === 0 ? 'font-bold text-[#0b1222]' : 'text-[#64748b]'}`}>
115
- {field.type === 'select' && doc[field.name!]
116
- ? (field as any).options?.find((o: any) => o.value === doc[field.name!])?.label || doc[field.name!]
117
- : String(doc[field.name!] || '—').slice(0, 60)}
118
- </td>
119
- ))}
120
- {config.timestamps && (
121
- <td class="px-6 py-5 text-[#64748b] text-sm">
122
- {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
123
- </td>
124
- )}
125
- <td class="px-6 py-5 text-right">
126
- <div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
127
- <a
128
- href={`/${collection}/${doc.id}`}
129
- class="inline-flex items-center justify-center w-8 h-8 rounded-md text-[#64748b] hover:bg-gray-100 hover:text-[#0b1222] transition-colors"
130
- onclick="event.stopPropagation()"
131
- >
132
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
133
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
134
- </svg>
135
- </a>
136
- <button
137
- onclick={`event.stopPropagation(); if(confirm('Delete this document?')) { fetch('/api/${collection}/${doc.id}', { method: 'DELETE' }).then(() => location.reload()); }`}
138
- class="inline-flex items-center justify-center w-8 h-8 rounded-md text-[#64748b] hover:bg-gray-200 hover:text-[#0b1222] transition-colors"
139
- >
140
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
141
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path>
142
- </svg>
143
- </button>
144
- </div>
145
- </td>
146
- </tr>
147
- ))
148
- )}
149
- </tbody>
150
- </table>
151
- </div>
152
-
153
- <!-- Pagination -->
154
- {totalDocs > limit && (
155
- <div class="flex items-center justify-between px-2">
156
- <span class="text-sm text-[#64748b] font-medium">
157
- Showing <span class="text-[#0b1222] font-bold">{(page - 1) * limit + 1}</span> to <span class="text-[#0b1222] font-bold">{Math.min(page * limit, totalDocs)}</span> of <span class="text-[#0b1222] font-bold">{totalDocs}</span>
158
- </span>
159
- <div class="flex gap-2">
160
- {page > 1 && (
161
- <a
162
- href={`/${collection}?page=${page - 1}&limit=${limit}`}
163
- class="px-4 py-2 border border-gray-200 rounded-lg text-sm font-bold text-[#0b1222] hover:bg-gray-50 transition-colors"
164
- >
165
- ← Previous
166
- </a>
167
- )}
168
- {page < totalPages && (
169
- <a
170
- href={`/${collection}?page=${page + 1}&limit=${limit}`}
171
- class="px-4 py-2 bg-[#0b1222] text-white rounded-lg text-sm font-bold hover:bg-[#1a2332] transition-colors"
172
- >
173
- Next →
174
- </a>
175
- )}
176
- </div>
177
- </div>
178
- )}
38
+ <AdminLayout title={config.label || collection || "Collection"}>
39
+ <div class="flex-1 overflow-y-auto p-8">
40
+ <EnhancedListView
41
+ client:load
42
+ collection={config}
43
+ collectionSlug={collection}
44
+ initialDocs={docs}
45
+ initialTotal={totalDocs}
46
+ />
179
47
  </div>
180
48
  </AdminLayout>