@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
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
3
|
-
import { collections } from
|
|
4
|
-
import { AutoForm } from
|
|
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 (
|
|
45
|
+
if (lookupId && lookupId !== "new") {
|
|
18
46
|
try {
|
|
19
|
-
const response = await fetch(
|
|
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(
|
|
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
|
|
31
|
-
const
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
121
|
-
const msg = document.getElementById(
|
|
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(
|
|
98
|
+
container.classList.remove("hidden");
|
|
125
99
|
if (!isError) {
|
|
126
|
-
setTimeout(() => container.classList.add(
|
|
100
|
+
setTimeout(() => container.classList.add("hidden"), 3000);
|
|
127
101
|
}
|
|
128
102
|
}
|
|
129
103
|
}
|
|
130
104
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const btn = document.getElementById(
|
|
135
|
-
|
|
136
|
-
if (btn) {
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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(
|
|
152
|
-
method,
|
|
153
|
-
|
|
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(
|
|
159
|
-
|
|
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 ||
|
|
193
|
+
showToast(error.error || "Failed to publish", true);
|
|
165
194
|
}
|
|
166
|
-
} catch (
|
|
167
|
-
showToast(
|
|
195
|
+
} catch (err) {
|
|
196
|
+
showToast("Failed to publish", true);
|
|
168
197
|
} finally {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
3
|
-
import { collections } from
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 ||
|
|
38
|
-
<div class="flex-1 overflow-y-auto p-8
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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>
|