@adminforge/core 0.3.1

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 (86) hide show
  1. package/.turbo/turbo-build.log +56 -0
  2. package/CHANGELOG.md +32 -0
  3. package/LICENSE +21 -0
  4. package/bin/adminforge.js +317 -0
  5. package/dist/auth-client.cjs +45 -0
  6. package/dist/auth-client.cjs.map +1 -0
  7. package/dist/auth-client.d.cts +17 -0
  8. package/dist/auth-client.d.ts +17 -0
  9. package/dist/auth-client.js +20 -0
  10. package/dist/auth-client.js.map +1 -0
  11. package/dist/auth.cjs +65 -0
  12. package/dist/auth.cjs.map +1 -0
  13. package/dist/auth.d.cts +21 -0
  14. package/dist/auth.d.ts +21 -0
  15. package/dist/auth.js +36 -0
  16. package/dist/auth.js.map +1 -0
  17. package/dist/client-D0cjJVsn.d.ts +20 -0
  18. package/dist/client-sRnmZ-Y9.d.cts +20 -0
  19. package/dist/index-CyzxaE7n.d.cts +124 -0
  20. package/dist/index-CyzxaE7n.d.ts +124 -0
  21. package/dist/index.cjs +453 -0
  22. package/dist/index.cjs.map +1 -0
  23. package/dist/index.d.cts +65 -0
  24. package/dist/index.d.ts +65 -0
  25. package/dist/index.js +410 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/next.cjs +839 -0
  28. package/dist/next.cjs.map +1 -0
  29. package/dist/next.d.cts +84 -0
  30. package/dist/next.d.ts +84 -0
  31. package/dist/next.js +800 -0
  32. package/dist/next.js.map +1 -0
  33. package/dist/styles.css +763 -0
  34. package/dist/styles.css.map +1 -0
  35. package/dist/styles.d.cts +2 -0
  36. package/dist/styles.d.ts +2 -0
  37. package/dist/ui.cjs +2500 -0
  38. package/dist/ui.cjs.map +1 -0
  39. package/dist/ui.d.cts +119 -0
  40. package/dist/ui.d.ts +119 -0
  41. package/dist/ui.js +2448 -0
  42. package/dist/ui.js.map +1 -0
  43. package/eslint.config.js +35 -0
  44. package/package.json +99 -0
  45. package/src/api/controller.ts +234 -0
  46. package/src/api/index.ts +4 -0
  47. package/src/api/next.ts +281 -0
  48. package/src/api/security/agent-auth.ts +134 -0
  49. package/src/auth/config.ts +20 -0
  50. package/src/auth/index.ts +3 -0
  51. package/src/auth/middleware.ts +15 -0
  52. package/src/auth/provider.tsx +28 -0
  53. package/src/core/fields/index.ts +119 -0
  54. package/src/core/hooks/index.ts +60 -0
  55. package/src/core/index.ts +43 -0
  56. package/src/core/registry/index.ts +22 -0
  57. package/src/core/schema/collection.ts +12 -0
  58. package/src/core/schema/config.ts +11 -0
  59. package/src/core/schema/normalize.ts +32 -0
  60. package/src/core/types/index.ts +114 -0
  61. package/src/db/client.ts +146 -0
  62. package/src/db/index.ts +3 -0
  63. package/src/db/schema-generator.ts +104 -0
  64. package/src/fields/index.ts +1 -0
  65. package/src/index.ts +4 -0
  66. package/src/next.ts +3 -0
  67. package/src/styles/adminforge.css +840 -0
  68. package/src/ui/AdminDashboard.tsx +176 -0
  69. package/src/ui/AdminForgeContext.tsx +64 -0
  70. package/src/ui/components/AdminLayout.tsx +107 -0
  71. package/src/ui/form-engine/FormEngine.tsx +250 -0
  72. package/src/ui/form-engine/ImageUpload.tsx +68 -0
  73. package/src/ui/form-engine/RelationInput.tsx +215 -0
  74. package/src/ui/form-engine/RichTextEditor.tsx +708 -0
  75. package/src/ui/index.ts +18 -0
  76. package/src/ui/screens/AdminPage.tsx +162 -0
  77. package/src/ui/screens/AgentTokenPage.tsx +232 -0
  78. package/src/ui/screens/CollectionFormPage.tsx +135 -0
  79. package/src/ui/screens/CollectionListPage.tsx +170 -0
  80. package/src/ui/screens/CollectionSchemaPage.tsx +180 -0
  81. package/src/ui/screens/RoleDetailPage.tsx +147 -0
  82. package/src/ui/screens/RolesListPage.tsx +57 -0
  83. package/src/ui/table-engine/TableEngine.tsx +157 -0
  84. package/src/ui.ts +3 -0
  85. package/tsconfig.json +10 -0
  86. package/tsup.config.ts +54 -0
@@ -0,0 +1,176 @@
1
+ "use client";
2
+ import React from "react";
3
+ import { AdminLayout } from "./components/AdminLayout.js";
4
+ import { AdminPage } from "./screens/AdminPage.js";
5
+ import { CollectionListPage } from "./screens/CollectionListPage.js";
6
+ import { CollectionFormPage } from "./screens/CollectionFormPage.js";
7
+ import { CollectionSchemaPage } from "./screens/CollectionSchemaPage.js";
8
+ import { RolesListPage } from "./screens/RolesListPage.js";
9
+ import { RoleDetailPage } from "./screens/RoleDetailPage.js";
10
+ import { AgentTokenPage } from "./screens/AgentTokenPage.js";
11
+ import type { AdminForgeConfig } from "../core/index.js";
12
+ import { useAdminForge, AdminForgeProvider } from "./AdminForgeContext.js";
13
+ import { useAdminSession } from "../auth/provider.js";
14
+ import { useSearchParams } from "next/navigation.js";
15
+
16
+ interface AdminDashboardProps {
17
+ config?: AdminForgeConfig;
18
+ params?: { admin?: string[] } | Promise<{ admin?: string[] }>;
19
+ apiBase?: string;
20
+ }
21
+
22
+ export function AdminDashboard({ config: initialConfig, params: initialParams, apiBase: initialApiBase }: AdminDashboardProps) {
23
+ const ctx = useAdminForge();
24
+ const config = initialConfig ?? ctx.config;
25
+ const apiBase = initialApiBase ?? ctx.apiBase;
26
+ const searchParams = useSearchParams();
27
+ const queryStr = searchParams.toString();
28
+
29
+ const [adminParams, setAdminParams] = React.useState<string[]>([]);
30
+ const [data, setData] = React.useState<any>(null);
31
+ const [record, setRecord] = React.useState<any>(null);
32
+ const [unauthorized, setUnauthorized] = React.useState(false);
33
+
34
+ React.useEffect(() => {
35
+ async function resolveParams() {
36
+ // If params is a promise (Next.js 15+), wait for it
37
+ const resolved = initialParams instanceof Promise ? await initialParams : initialParams;
38
+
39
+ if (resolved?.admin) {
40
+ setAdminParams(resolved.admin);
41
+ } else if (typeof window !== "undefined") {
42
+ // Fallback to window.location if params are missing
43
+ const path = window.location.pathname;
44
+ const segments = path.split("/admin/").pop()?.split("/") || [];
45
+ setAdminParams(segments.filter(Boolean));
46
+ }
47
+ }
48
+
49
+ resolveParams();
50
+ }, [initialParams]);
51
+
52
+ const [segment, actionOrId] = adminParams;
53
+
54
+ React.useEffect(() => {
55
+ if (!config || !segment) return;
56
+ const isCollection = config.collections.some((c: any) => c.name === segment);
57
+ if (!isCollection) return;
58
+
59
+ const query = queryStr ? `?${queryStr}` : "";
60
+
61
+ if (!actionOrId) {
62
+ fetch(`${apiBase}/${segment}${query}`)
63
+ .then(async res => {
64
+ if (res.status === 401) {
65
+ setUnauthorized(true);
66
+ return null;
67
+ }
68
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
69
+ const text = await res.text();
70
+ return text ? JSON.parse(text) : {};
71
+ })
72
+ .then(res => setData(res))
73
+ .catch(e => console.error(`[AdminForge] Failed to fetch ${apiBase}/${segment}:`, e));
74
+ } else if (actionOrId !== "new" && actionOrId !== "schema") {
75
+ fetch(`${apiBase}/${segment}/${actionOrId}${query}`)
76
+ .then(async res => {
77
+ if (res.status === 401) {
78
+ setUnauthorized(true);
79
+ return null;
80
+ }
81
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
82
+ const text = await res.text();
83
+ return text ? JSON.parse(text) : {};
84
+ })
85
+ .then(res => setRecord(res))
86
+ .catch(e => console.error(`[AdminForge] Failed to fetch ${apiBase}/${segment}/${actionOrId}:`, e));
87
+ }
88
+ }, [segment, actionOrId, apiBase, config, queryStr]);
89
+
90
+ return (
91
+ <AdminForgeProvider config={config} apiBase={apiBase}>
92
+ <AdminDashboardContent
93
+ config={config}
94
+ adminParams={adminParams}
95
+ unauthorized={unauthorized}
96
+ data={data}
97
+ record={record}
98
+ />
99
+ </AdminForgeProvider>
100
+ );
101
+ }
102
+
103
+ function AdminDashboardContent({ config: _config, adminParams, unauthorized: localUnauthorized, data, record }: any) {
104
+ const { config: ctxConfig, unauthorized: ctxUnauthorized } = useAdminForge();
105
+ const config = _config ?? ctxConfig;
106
+ const session = useAdminSession();
107
+ const noSession = config?.auth?.enabled && !session?.user;
108
+ const unauthorized = localUnauthorized || ctxUnauthorized || noSession;
109
+
110
+ if (unauthorized) {
111
+ return (
112
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#f8fafc', padding: '20px' }}>
113
+ <div style={{ background: '#fff', padding: '40px', borderRadius: '16px', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1)', maxWidth: '400px', width: '100%', textAlign: 'center' }}>
114
+ <div style={{ width: '64px', height: '64px', background: '#eff6ff', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
115
+ <span className="material-symbols-outlined" style={{ color: '#3b82f6', fontSize: '32px' }}>lock</span>
116
+ </div>
117
+ <h2 style={{ fontSize: '24px', fontWeight: 700, color: '#0f172a', marginBottom: '8px' }}>Login Required</h2>
118
+ <p style={{ color: '#64748b', fontSize: '15px', marginBottom: '32px', lineHeight: 1.6 }}>Please sign in to access the AdminForge dashboard.</p>
119
+ <form action="/api/auth/signin" method="GET">
120
+ <button className="adminforge-btn adminforge-btn-primary" style={{ width: '100%', height: '48px', fontSize: '16px' }}>
121
+ Sign In to Admin
122
+ </button>
123
+ </form>
124
+ </div>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ if (config) {
130
+ const [segment, actionOrId] = adminParams;
131
+
132
+ if (adminParams.length === 0) return <AdminPage config={config} />;
133
+
134
+ if (segment === "roles") {
135
+ if (actionOrId) return <RoleDetailPage config={config} roleId={actionOrId} />;
136
+ return <RolesListPage config={config} />;
137
+ }
138
+
139
+ if (segment === "settings" && actionOrId === "agent-tokens") {
140
+ return <AgentTokenPage config={config} />;
141
+ }
142
+
143
+ const collection = config.collections.find((c: any) => c.name === segment);
144
+ if (collection) {
145
+ if (actionOrId === "new") return <CollectionFormPage config={config} collection={collection} isNew />;
146
+ if (actionOrId === "schema") return <CollectionSchemaPage config={config} collection={collection} />;
147
+ if (actionOrId) return <CollectionFormPage config={config} collection={collection} isNew={false} record={record} />;
148
+
149
+ return (
150
+ <CollectionListPage
151
+ config={config}
152
+ collection={collection}
153
+ data={data?.data || []}
154
+ total={data?.total || 0}
155
+ page={data?.page || 1}
156
+ pageSize={data?.pageSize || 10}
157
+ />
158
+ );
159
+ }
160
+
161
+ return <AdminPage config={config} />;
162
+ }
163
+
164
+ return (
165
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#f8fafc' }}>
166
+ <div style={{ textAlign: 'center' }}>
167
+ <div className="adminforge-spinner" style={{ width: '40px', height: '40px', border: '3px solid #e2e8f0', borderTopColor: '#3b82f6', borderRadius: '50%', margin: '0 auto 16px' }}></div>
168
+ <p style={{ color: '#64748b', fontSize: '14px', fontWeight: 500 }}>Loading AdminForge...</p>
169
+ </div>
170
+ <style>{`
171
+ .adminforge-spinner { animation: spin 0.8s linear infinite; }
172
+ @keyframes spin { to { transform: rotate(360deg); } }
173
+ `}</style>
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import React, { createContext, useContext } from "react";
4
+ import type { AdminForgeConfig } from "../core/index.js";
5
+
6
+ interface AdminForgeContextType {
7
+ config: AdminForgeConfig | undefined;
8
+ apiBase: string;
9
+ unauthorized: boolean;
10
+ }
11
+
12
+ const AdminForgeContext = createContext<AdminForgeContextType>({
13
+ config: undefined,
14
+ apiBase: "/api/adminforge",
15
+ unauthorized: false
16
+ });
17
+
18
+ export function AdminForgeProvider({
19
+ children,
20
+ config: initialConfig,
21
+ apiBase = "/api"
22
+ }: {
23
+ children: React.ReactNode;
24
+ config?: AdminForgeConfig;
25
+ apiBase?: string;
26
+ }) {
27
+ const [config, setConfig] = React.useState<AdminForgeConfig | undefined>(initialConfig);
28
+ const [unauthorized, setUnauthorized] = React.useState(false);
29
+
30
+ React.useEffect(() => {
31
+ // 1. Inject Material Symbols if missing
32
+ if (typeof document !== "undefined" && !document.getElementById("adminforge-fonts")) {
33
+ const link = document.createElement("link");
34
+ link.id = "adminforge-fonts";
35
+ link.rel = "stylesheet";
36
+ link.href = "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200";
37
+ document.head.appendChild(link);
38
+ }
39
+
40
+ // 2. Auto-fetch config if not provided
41
+ if (!config) {
42
+ fetch(`${apiBase}/_config`)
43
+ .then(res => {
44
+ if (res.status === 401) {
45
+ setUnauthorized(true);
46
+ return null;
47
+ }
48
+ return res.ok ? res.json() : null;
49
+ })
50
+ .then(cfg => cfg?.collections ? setConfig(cfg) : null)
51
+ .catch(e => console.error("[AdminForge] Failed to fetch config:", e));
52
+ }
53
+ }, [config, apiBase]);
54
+
55
+ return (
56
+ <AdminForgeContext.Provider value={{ config, apiBase, unauthorized }}>
57
+ {children}
58
+ </AdminForgeContext.Provider>
59
+ );
60
+ }
61
+
62
+ export function useAdminForge() {
63
+ return useContext(AdminForgeContext);
64
+ }
@@ -0,0 +1,107 @@
1
+ import type { AdminForgeConfig } from "../../core";
2
+ import Link from "next/link";
3
+
4
+ interface AdminLayoutProps {
5
+ config: AdminForgeConfig;
6
+ children: React.ReactNode;
7
+ currentPath?: string;
8
+ role?: string;
9
+ }
10
+
11
+ const iconMap: Record<string, string> = {
12
+ posts: "article",
13
+ categories: "category",
14
+ tags: "sell",
15
+ users: "person",
16
+ };
17
+
18
+ export function AdminLayout({ config, children, currentPath, role }: AdminLayoutProps) {
19
+ return (
20
+ <div className="adminforge-layout">
21
+ <nav className="adminforge-sidebar">
22
+ <div className="adminforge-sidebar-header">
23
+ <Link href="/admin">
24
+ <h1>AdminForge</h1>
25
+ </Link>
26
+ <p className="adminforge-sidebar-subtitle">Collections Manager</p>
27
+ </div>
28
+ <ul className="adminforge-nav">
29
+ <li>
30
+ <Link href="/admin" className={`adminforge-nav-link ${currentPath === "/admin" ? "active" : ""}`}>
31
+ <div className="adminforge-nav-item-content">
32
+ <span className="material-symbols-outlined adminforge-nav-icon">dashboard</span>
33
+ <span>Overview</span>
34
+ </div>
35
+ </Link>
36
+ </li>
37
+ {config.collections.map((collection) => {
38
+ const a = collection.access;
39
+ if (a?.read && (!role || !a.read.includes(role))) return null;
40
+ const href = `/admin/${collection.name}`;
41
+ const icon = collection.icon || "database";
42
+ return (
43
+ <li key={collection.name} className="adminforge-nav-item">
44
+ <Link href={href} className={`adminforge-nav-link ${currentPath === href ? "active" : ""}`}>
45
+ <div className="adminforge-nav-item-content">
46
+ <span className="material-symbols-outlined adminforge-nav-icon">{icon}</span>
47
+ <span>{collection.label}</span>
48
+ </div>
49
+ </Link>
50
+ <Link href={`${href}/new`} className="adminforge-nav-quick-create" title={`Create New ${collection.label}`}>
51
+ <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span>
52
+ </Link>
53
+ </li>
54
+ );
55
+ })}
56
+
57
+ <li className="adminforge-nav-section-title" style={{ marginTop: '24px', padding: '8px 16px', fontSize: '11px', fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
58
+ Access Control
59
+ </li>
60
+ <li>
61
+ <Link href="/admin/roles" className={`adminforge-nav-link ${currentPath?.startsWith("/admin/roles") ? "active" : ""}`}>
62
+ <div className="adminforge-nav-item-content">
63
+ <span className="material-symbols-outlined adminforge-nav-icon">shield_person</span>
64
+ <span>Roles</span>
65
+ </div>
66
+ </Link>
67
+ </li>
68
+
69
+ <li className="adminforge-nav-section-title" style={{ marginTop: '24px', padding: '8px 16px', fontSize: '11px', fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
70
+ AI & Agents
71
+ </li>
72
+ <li>
73
+ <Link href="/admin/settings/agent-tokens" className={`adminforge-nav-link ${currentPath === "/admin/settings/agent-tokens" ? "active" : ""}`}>
74
+ <div className="adminforge-nav-item-content">
75
+ <span className="material-symbols-outlined adminforge-nav-icon">smart_toy</span>
76
+ <span>Agent Tokens</span>
77
+ </div>
78
+ </Link>
79
+ </li>
80
+ </ul>
81
+ </nav>
82
+ <main className="adminforge-content">
83
+ <header className="adminforge-topbar">
84
+ <h2 style={{ fontSize: "1.125rem", fontWeight: 600 }}>
85
+ {currentPath === "/admin" ? "Dashboard" : "Management"}
86
+ </h2>
87
+ <div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
88
+ {role && (
89
+ <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
90
+ <div style={{ width: "8px", height: "8px", borderRadius: "50%", background: "#10b981" }}></div>
91
+ <span style={{ fontSize: "13px", fontWeight: 500, color: "var(--color-text-secondary)" }}>{role}</span>
92
+ </div>
93
+ )}
94
+ {config.auth?.enabled && (
95
+ <form action="/api/logout" method="POST">
96
+ <button type="submit" className="adminforge-btn adminforge-btn-secondary" style={{ padding: "6px 12px", fontSize: "13px" }}>
97
+ Log Out
98
+ </button>
99
+ </form>
100
+ )}
101
+ </div>
102
+ </header>
103
+ <div className="adminforge-main-canvas">{children}</div>
104
+ </main>
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,250 @@
1
+ "use client";
2
+
3
+ import type { CollectionDefinition, FieldDefinition, AccessConfig } from "../../core";
4
+ import { useCallback, useRef, useState, useEffect } from "react";
5
+ import { RichTextEditor } from "./RichTextEditor.js";
6
+ import { ImageUpload } from "./ImageUpload.js";
7
+ import { RelationInput } from "./RelationInput.js";
8
+
9
+ interface FormEngineProps {
10
+ collection: CollectionDefinition;
11
+ record?: Record<string, unknown> | null;
12
+ isNew: boolean;
13
+ role?: string;
14
+ }
15
+
16
+ function hasAccess(access: AccessConfig | undefined, operation: string, role?: string): boolean {
17
+ if (!access) return true;
18
+ const allowed = access[operation as keyof AccessConfig];
19
+ if (!allowed || !Array.isArray(allowed)) return true;
20
+ if (!role) return false;
21
+ return allowed.includes(role);
22
+ }
23
+
24
+ function FieldRenderer({
25
+ name, field, value, onChange, onRelationChange, error, role, checked, onCheckedChange,
26
+ }: {
27
+ name: string; field: FieldDefinition; value?: unknown; onChange?: (val: string) => void;
28
+ onRelationChange?: (val: string | string[]) => void; error?: string; role?: string;
29
+ checked?: boolean; onCheckedChange?: (checked: boolean) => void;
30
+ }) {
31
+ const { component, props } = field.ui;
32
+ if (props?.hidden) return null;
33
+ if (!hasAccess(field.access, "read", role)) return null;
34
+
35
+ const errorClass = error ? "adminforge-input-error" : "";
36
+ const resolvedValue = value !== undefined ? value : field.db?.default;
37
+ const isReadOnly = Boolean(props?.readOnly) || !hasAccess(field.access, "update", role);
38
+
39
+ switch (component) {
40
+ case "text": case "slug": case "date":
41
+ return (
42
+ <div className="adminforge-field">
43
+ <label htmlFor={name}>{(props?.label as string) ?? name}</label>
44
+ <input id={name} name={name} type={component === "date" ? "datetime-local" : "text"}
45
+ className={`adminforge-input ${errorClass}`} required={!field.db?.nullable}
46
+ defaultValue={typeof resolvedValue === "string" ? resolvedValue : ""} readOnly={isReadOnly} />
47
+ {error && <span className="adminforge-field-err">{error}</span>}
48
+ </div>
49
+ );
50
+ case "boolean":
51
+ return (
52
+ <div className="adminforge-field adminforge-field-checkbox">
53
+ <label htmlFor={name}>
54
+ <input id={name} name={name} type="checkbox"
55
+ checked={!!checked}
56
+ onChange={(e) => onCheckedChange?.(e.target.checked)}
57
+ disabled={isReadOnly} />
58
+ {(props?.label as string) ?? name}
59
+ </label>
60
+ {error && <span className="adminforge-field-err">{error}</span>}
61
+ </div>
62
+ );
63
+ case "relation":
64
+ return (
65
+ <div className="adminforge-field">
66
+ <label>{(props?.label as string) ?? name}</label>
67
+ <RelationInput name={name} to={props?.to as string} relationType={props?.relationType as string}
68
+ value={resolvedValue as string | string[] | undefined} onChange={onRelationChange} error={error} disabled={isReadOnly} />
69
+ {error && <span className="adminforge-field-err">{error}</span>}
70
+ </div>
71
+ );
72
+ case "richText":
73
+ return (
74
+ <div className="adminforge-field">
75
+ <label>{(props?.label as string) ?? name}</label>
76
+ <RichTextEditor name={name} value={typeof resolvedValue === "string" ? resolvedValue : ""}
77
+ onChange={(val) => !isReadOnly && onChange?.(val)} />
78
+ <input type="hidden" name={name} id={`${name}-hidden`} defaultValue={typeof resolvedValue === "string" ? resolvedValue : ""} />
79
+ {error && <span className="adminforge-field-err">{error}</span>}
80
+ </div>
81
+ );
82
+ case "image":
83
+ return <ImageUpload name={name} value={typeof resolvedValue === "string" ? resolvedValue : ""}
84
+ onChange={(val) => !isReadOnly && onChange?.(val)} />;
85
+ default:
86
+ return (
87
+ <div className="adminforge-field">
88
+ <label htmlFor={name}>{(props?.label as string) ?? name}</label>
89
+ <input id={name} name={name} type="text" className={`adminforge-input ${errorClass}`}
90
+ defaultValue={typeof resolvedValue === "string" ? resolvedValue : ""} readOnly={isReadOnly} />
91
+ {error && <span className="adminforge-field-err">{error}</span>}
92
+ </div>
93
+ );
94
+ }
95
+ }
96
+
97
+ export function FormEngine({ collection, record, isNew, role }: FormEngineProps) {
98
+ const formRef = useRef<HTMLFormElement>(null);
99
+ const [richTextValues, setRichTextValues] = useState<Record<string, string>>({});
100
+ const [relationValues, setRelationValues] = useState<Record<string, string | string[]>>({});
101
+ const [booleanValues, setBooleanValues] = useState<Record<string, boolean>>({});
102
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
103
+ const [submitError, setSubmitError] = useState<string | null>(null);
104
+
105
+ const canSave = isNew
106
+ ? hasAccess(collection.access, "create", role)
107
+ : hasAccess(collection.access, "update", role);
108
+
109
+ useEffect(() => {
110
+ if (record && !isNew) {
111
+ const rt: Record<string, string> = {};
112
+ const rv: Record<string, string | string[]> = {};
113
+ const bv: Record<string, boolean> = {};
114
+ for (const [key, value] of Object.entries(record)) {
115
+ const field = collection.fields[key];
116
+ if (field?.type === "richText" && typeof value === "string") rt[key] = value;
117
+ if (field?.type === "relation") {
118
+ if (Array.isArray(value)) rv[key] = value.map((v: any) => (typeof v === "string" ? v : v.id));
119
+ else if (typeof value === "string") rv[key] = value;
120
+ else if (value && typeof value === "object" && (value as any).id) rv[key] = (value as any).id;
121
+ }
122
+ if (field?.type === "boolean") bv[key] = !!value;
123
+ }
124
+ setRichTextValues(rt);
125
+ setRelationValues(rv);
126
+ setBooleanValues(bv);
127
+ }
128
+ }, [record, isNew, collection.fields]);
129
+
130
+ useEffect(() => {
131
+ if (!isNew) return;
132
+ const form = formRef.current;
133
+ if (!form) return;
134
+ const listeners: { input: HTMLInputElement; fn: EventListener }[] = [];
135
+ Object.entries(collection.fields).forEach(([name, field]) => {
136
+ if (field.type === "slug" && field.ui.props?.from) {
137
+ const src = form.querySelector(`[name="${field.ui.props.from}"]`) as HTMLInputElement | null;
138
+ const dst = form.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
139
+ if (src && dst) {
140
+ const fn = (e: Event) => {
141
+ const val = (e.target as HTMLInputElement).value;
142
+ dst.value = val.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
143
+ };
144
+ src.addEventListener("input", fn);
145
+ listeners.push({ input: src, fn });
146
+ }
147
+ }
148
+ });
149
+ return () => listeners.forEach((l) => l.input.removeEventListener("input", l.fn));
150
+ }, [isNew, collection.fields]);
151
+
152
+ const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
153
+ e.preventDefault();
154
+ if (!canSave) return;
155
+ setFieldErrors({});
156
+ setSubmitError(null);
157
+ const form = e.currentTarget;
158
+ const formData = new FormData(form);
159
+ const data: Record<string, unknown> = {};
160
+ for (const [key, value] of formData.entries()) {
161
+ if (value instanceof File) continue;
162
+ const field = collection.fields[key];
163
+ if (!hasAccess(field?.access, isNew ? "create" : "update", role)) continue;
164
+ if (richTextValues[key]) { data[key] = richTextValues[key]; }
165
+ else if (field?.type === "boolean") { continue; } // handled below from state
166
+ else if (field?.type === "relation") { continue; }
167
+ else { data[key] = value.toString(); }
168
+ }
169
+ // Merge boolean values from controlled state
170
+ for (const [name, field] of Object.entries(collection.fields)) {
171
+ if (field.type === "boolean" && hasAccess(field.access, isNew ? "create" : "update", role)) {
172
+ data[name] = booleanValues[name] ?? false;
173
+ }
174
+ }
175
+ for (const [key, val] of Object.entries(relationValues)) {
176
+ if (collection.fields[key] && hasAccess(collection.fields[key]?.access, isNew ? "create" : "update", role)) {
177
+ data[key] = val;
178
+ }
179
+ }
180
+ const url = isNew ? `/api/${collection.name}` : `/api/${collection.name}/${record?.id}`;
181
+ const method = isNew ? "POST" : "PATCH";
182
+ const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
183
+ if (res.ok) { window.location.href = `/admin/${collection.name}`; }
184
+ else {
185
+ const err = await res.json().catch(() => ({ error: "Request failed" }));
186
+ if (err.fields) {
187
+ const errors: Record<string, string> = {};
188
+ for (const f of err.fields as { path: string; message: string }[]) errors[f.path] = f.message;
189
+ setFieldErrors(errors);
190
+ }
191
+ setSubmitError(err.error ?? "An error occurred");
192
+ }
193
+ }, [collection, isNew, record?.id, richTextValues, relationValues, booleanValues, role, canSave]);
194
+
195
+ const handleRichTextChange = useCallback((name: string, value: string) => {
196
+ setRichTextValues((prev) => {
197
+ const next = { ...prev, [name]: value };
198
+ const hidden = formRef.current?.querySelector(`#${name}-hidden`) as HTMLInputElement | null;
199
+ if (hidden) hidden.value = value;
200
+ return next;
201
+ });
202
+ }, []);
203
+
204
+ return (
205
+ <form ref={formRef} onSubmit={handleSubmit} className="adminforge-form">
206
+ {submitError && <div className="adminforge-form-error">{submitError}</div>}
207
+
208
+ {!isNew && !!record?.id && (
209
+ <div className="adminforge-field">
210
+ <label>Internal ID</label>
211
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
212
+ <input
213
+ type="text"
214
+ className="adminforge-input"
215
+ value={String(record.id)}
216
+ readOnly
217
+ style={{ background: '#f8fafc', color: '#64748b', fontFamily: 'monospace' }}
218
+ />
219
+ <button
220
+ type="button"
221
+ className="adminforge-btn-icon"
222
+ title="Copy ID"
223
+ onClick={() => navigator.clipboard.writeText(String(record.id))}
224
+ >
225
+ <span className="material-symbols-outlined">content_copy</span>
226
+ </button>
227
+ </div>
228
+ </div>
229
+ )}
230
+ {Object.entries(collection.fields).map(([name, field]) => {
231
+ const fv = field.type === "relation" && relationValues[name] !== undefined ? relationValues[name] : record?.[name];
232
+ return (
233
+ <FieldRenderer key={name} name={name} field={field} value={fv}
234
+ onChange={(val) => handleRichTextChange(name, val)}
235
+ onRelationChange={(val) => setRelationValues((prev) => ({ ...prev, [name]: val }))}
236
+ checked={field.type === "boolean" ? (booleanValues[name] ?? false) : undefined}
237
+ onCheckedChange={field.type === "boolean" ? (checked) => setBooleanValues((prev) => ({ ...prev, [name]: checked })) : undefined}
238
+ error={fieldErrors[name]} role={role} />
239
+ );
240
+ })}
241
+ {canSave && (
242
+ <div className="adminforge-form-actions">
243
+ <button type="submit" className="adminforge-btn adminforge-btn-primary">
244
+ {isNew ? "Create" : "Save"}
245
+ </button>
246
+ </div>
247
+ )}
248
+ </form>
249
+ );
250
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useState, useRef } from "react";
4
+ import { useAdminForge } from "../AdminForgeContext.js";
5
+
6
+ interface ImageUploadProps {
7
+ name: string;
8
+ value?: string;
9
+ onChange: (value: string) => void;
10
+ }
11
+
12
+ export function ImageUpload({ name, value, onChange }: ImageUploadProps) {
13
+ const { apiBase } = useAdminForge();
14
+ const [uploading, setUploading] = useState(false);
15
+ const [preview, setPreview] = useState(value ?? "");
16
+ const inputRef = useRef<HTMLInputElement>(null);
17
+
18
+ async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
19
+ const file = e.target.files?.[0];
20
+ if (!file) return;
21
+
22
+ setUploading(true);
23
+ const formData = new FormData();
24
+ formData.append("file", file);
25
+
26
+ try {
27
+ const res = await fetch(`${apiBase}/_media`, {
28
+ method: "POST",
29
+ body: formData,
30
+ });
31
+ if (!res.ok) throw new Error("Upload failed");
32
+ const data = await res.json();
33
+ onChange(data.url);
34
+ setPreview(data.url);
35
+ } catch {
36
+ alert("Upload failed");
37
+ } finally {
38
+ setUploading(false);
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div className="adminforge-field">
44
+ <label>{(name.charAt(0).toUpperCase() + name.slice(1))} Image</label>
45
+ <input
46
+ ref={inputRef}
47
+ type="file"
48
+ accept="image/*"
49
+ onChange={handleFile}
50
+ style={{ display: "none" }}
51
+ />
52
+ <div className="adminforge-image-upload">
53
+ {preview && (
54
+ <img src={preview} alt="Preview" className="adminforge-image-preview" />
55
+ )}
56
+ <button
57
+ type="button"
58
+ className="adminforge-btn adminforge-btn-secondary"
59
+ onClick={() => inputRef.current?.click()}
60
+ disabled={uploading}
61
+ >
62
+ {uploading ? "Uploading..." : preview ? "Replace Image" : "Choose Image"}
63
+ </button>
64
+ </div>
65
+ <input type="hidden" name={name} value={preview} />
66
+ </div>
67
+ );
68
+ }