@structcms/admin 0.1.0
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/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.cjs +2761 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +736 -0
- package/dist/index.d.ts +736 -0
- package/dist/index.js +2686 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2761 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
AdminLayout: () => AdminLayout,
|
|
34
|
+
AdminProvider: () => AdminProvider,
|
|
35
|
+
ArrayField: () => ArrayField,
|
|
36
|
+
AuthProvider: () => AuthProvider,
|
|
37
|
+
Button: () => Button,
|
|
38
|
+
DashboardPage: () => DashboardPage,
|
|
39
|
+
Dialog: () => Dialog,
|
|
40
|
+
ErrorBoundary: () => ErrorBoundary,
|
|
41
|
+
FormGenerator: () => FormGenerator,
|
|
42
|
+
ImagePicker: () => ImagePicker,
|
|
43
|
+
Input: () => Input,
|
|
44
|
+
KpiCards: () => KpiCards,
|
|
45
|
+
Label: () => Label,
|
|
46
|
+
LoginForm: () => LoginForm,
|
|
47
|
+
MediaBrowser: () => MediaBrowser,
|
|
48
|
+
NavigationEditor: () => NavigationEditor,
|
|
49
|
+
OAuthButton: () => OAuthButton,
|
|
50
|
+
ObjectField: () => ObjectField,
|
|
51
|
+
PageEditor: () => PageEditor,
|
|
52
|
+
PageList: () => PageList,
|
|
53
|
+
ProtectedRoute: () => ProtectedRoute,
|
|
54
|
+
QuickActions: () => QuickActions,
|
|
55
|
+
RecentPages: () => RecentPages,
|
|
56
|
+
RichTextEditor: () => RichTextEditor,
|
|
57
|
+
SectionEditor: () => SectionEditor,
|
|
58
|
+
Skeleton: () => Skeleton,
|
|
59
|
+
StringInput: () => StringInput,
|
|
60
|
+
StructCMSAdminApp: () => StructCMSAdminApp,
|
|
61
|
+
TextInput: () => TextInput,
|
|
62
|
+
Textarea: () => Textarea,
|
|
63
|
+
ToastProvider: () => ToastProvider,
|
|
64
|
+
buttonVariants: () => buttonVariants,
|
|
65
|
+
cn: () => cn,
|
|
66
|
+
fieldNameToLabel: () => fieldNameToLabel,
|
|
67
|
+
resolveFieldType: () => resolveFieldType,
|
|
68
|
+
useAdmin: () => useAdmin,
|
|
69
|
+
useApiClient: () => useApiClient,
|
|
70
|
+
useAuth: () => useAuth,
|
|
71
|
+
useToast: () => useToast
|
|
72
|
+
});
|
|
73
|
+
module.exports = __toCommonJS(index_exports);
|
|
74
|
+
|
|
75
|
+
// src/components/app/struct-cms-admin-app.tsx
|
|
76
|
+
var import_react6 = require("react");
|
|
77
|
+
|
|
78
|
+
// src/context/admin-context.tsx
|
|
79
|
+
var import_react = require("react");
|
|
80
|
+
|
|
81
|
+
// src/components/ui/toast.tsx
|
|
82
|
+
var React = __toESM(require("react"), 1);
|
|
83
|
+
|
|
84
|
+
// src/lib/utils.ts
|
|
85
|
+
var import_clsx = require("clsx");
|
|
86
|
+
var import_tailwind_merge = require("tailwind-merge");
|
|
87
|
+
function cn(...inputs) {
|
|
88
|
+
return (0, import_tailwind_merge.twMerge)((0, import_clsx.clsx)(inputs));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/components/ui/toast.tsx
|
|
92
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
93
|
+
var ToastContext = React.createContext(null);
|
|
94
|
+
function ToastProvider({ children, autoDismissMs = 5e3 }) {
|
|
95
|
+
const [toasts, setToasts] = React.useState([]);
|
|
96
|
+
const counterRef = React.useRef(0);
|
|
97
|
+
const removeToast = React.useCallback((id) => {
|
|
98
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
99
|
+
}, []);
|
|
100
|
+
const addToast = React.useCallback(
|
|
101
|
+
(message, variant = "default") => {
|
|
102
|
+
const id = `toast-${++counterRef.current}`;
|
|
103
|
+
const toast = { id, message, variant };
|
|
104
|
+
setToasts((prev) => [...prev, toast]);
|
|
105
|
+
if (autoDismissMs > 0) {
|
|
106
|
+
setTimeout(() => removeToast(id), autoDismissMs);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[autoDismissMs, removeToast]
|
|
110
|
+
);
|
|
111
|
+
const value = React.useMemo(
|
|
112
|
+
() => ({ toasts, addToast, removeToast }),
|
|
113
|
+
[toasts, addToast, removeToast]
|
|
114
|
+
);
|
|
115
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(ToastContext.Provider, { value, children: [
|
|
116
|
+
children,
|
|
117
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ToastContainer, { toasts, onDismiss: removeToast })
|
|
118
|
+
] });
|
|
119
|
+
}
|
|
120
|
+
ToastProvider.displayName = "ToastProvider";
|
|
121
|
+
function useToast() {
|
|
122
|
+
const context = React.useContext(ToastContext);
|
|
123
|
+
if (!context) {
|
|
124
|
+
throw new Error("useToast must be used within a ToastProvider");
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
toast: context.addToast,
|
|
128
|
+
dismiss: context.removeToast,
|
|
129
|
+
toasts: context.toasts
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
var variantStyles = {
|
|
133
|
+
default: "bg-card border-input text-foreground",
|
|
134
|
+
success: "bg-card border-green-500 text-foreground",
|
|
135
|
+
error: "bg-card border-destructive text-foreground"
|
|
136
|
+
};
|
|
137
|
+
function ToastContainer({ toasts, onDismiss }) {
|
|
138
|
+
if (toasts.length === 0) return null;
|
|
139
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
140
|
+
"div",
|
|
141
|
+
{
|
|
142
|
+
className: "fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm",
|
|
143
|
+
"data-testid": "toast-container",
|
|
144
|
+
children: toasts.map((toast) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
145
|
+
"div",
|
|
146
|
+
{
|
|
147
|
+
className: cn(
|
|
148
|
+
"rounded-md border px-4 py-3 shadow-md flex items-center justify-between gap-2",
|
|
149
|
+
variantStyles[toast.variant ?? "default"]
|
|
150
|
+
),
|
|
151
|
+
role: "alert",
|
|
152
|
+
"data-testid": `toast-${toast.id}`,
|
|
153
|
+
children: [
|
|
154
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "text-sm", children: toast.message }),
|
|
155
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
156
|
+
"button",
|
|
157
|
+
{
|
|
158
|
+
type: "button",
|
|
159
|
+
className: "text-muted-foreground hover:text-foreground text-sm",
|
|
160
|
+
onClick: () => onDismiss(toast.id),
|
|
161
|
+
"data-testid": `toast-dismiss-${toast.id}`,
|
|
162
|
+
children: "\u2715"
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
]
|
|
166
|
+
},
|
|
167
|
+
toast.id
|
|
168
|
+
))
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/context/admin-context.tsx
|
|
174
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
175
|
+
var DEFAULT_API_BASE_URL = "/api/cms";
|
|
176
|
+
var AdminContext = (0, import_react.createContext)(null);
|
|
177
|
+
function AdminProvider({
|
|
178
|
+
children,
|
|
179
|
+
registry,
|
|
180
|
+
apiBaseUrl = DEFAULT_API_BASE_URL
|
|
181
|
+
}) {
|
|
182
|
+
const value = {
|
|
183
|
+
registry,
|
|
184
|
+
apiBaseUrl
|
|
185
|
+
};
|
|
186
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AdminContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ToastProvider, { children }) });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/context/auth-context.tsx
|
|
190
|
+
var import_react2 = require("react");
|
|
191
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
192
|
+
var AuthContext = (0, import_react2.createContext)(void 0);
|
|
193
|
+
function getCsrfToken() {
|
|
194
|
+
if (typeof document === "undefined") return null;
|
|
195
|
+
const match = document.cookie.match(/structcms_csrf_token=([^;]+)/);
|
|
196
|
+
return match?.[1] ?? null;
|
|
197
|
+
}
|
|
198
|
+
function createFetchOptions(method, body) {
|
|
199
|
+
const options = {
|
|
200
|
+
method,
|
|
201
|
+
credentials: "include",
|
|
202
|
+
// Always send cookies
|
|
203
|
+
headers: {
|
|
204
|
+
"Content-Type": "application/json"
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
208
|
+
const csrfToken = getCsrfToken();
|
|
209
|
+
if (csrfToken) {
|
|
210
|
+
options.headers["X-CSRF-Token"] = csrfToken;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (body) {
|
|
214
|
+
options.body = JSON.stringify(body);
|
|
215
|
+
}
|
|
216
|
+
return options;
|
|
217
|
+
}
|
|
218
|
+
function AuthProvider({ children, apiBaseUrl, onAuthStateChange }) {
|
|
219
|
+
const isAuthDisabled = process.env.NODE_ENV === "development" && typeof window !== "undefined" && (process.env.NEXT_PUBLIC_DISABLE_AUTH === "true" || // biome-ignore lint/suspicious/noExplicitAny: Next.js internal data structure
|
|
220
|
+
window.__NEXT_DATA__?.props?.pageProps?.disableAuth === true);
|
|
221
|
+
if (isAuthDisabled) {
|
|
222
|
+
console.warn("\u26A0\uFE0F WARNING: Authentication is DISABLED. This should only be used in development!");
|
|
223
|
+
}
|
|
224
|
+
const [user, setUser] = (0, import_react2.useState)(null);
|
|
225
|
+
const [session, setSession] = (0, import_react2.useState)(null);
|
|
226
|
+
const [isLoading, setIsLoading] = (0, import_react2.useState)(!isAuthDisabled);
|
|
227
|
+
const clearSession = (0, import_react2.useCallback)(() => {
|
|
228
|
+
setUser(null);
|
|
229
|
+
setSession(null);
|
|
230
|
+
onAuthStateChange?.(null);
|
|
231
|
+
}, [onAuthStateChange]);
|
|
232
|
+
const tryRefreshSession = (0, import_react2.useCallback)(async () => {
|
|
233
|
+
try {
|
|
234
|
+
const refreshResponse = await fetch(
|
|
235
|
+
`${apiBaseUrl}/auth/refresh`,
|
|
236
|
+
createFetchOptions("POST")
|
|
237
|
+
);
|
|
238
|
+
if (refreshResponse.ok) {
|
|
239
|
+
const data = await refreshResponse.json();
|
|
240
|
+
setSession({
|
|
241
|
+
accessToken: "",
|
|
242
|
+
// Not accessible from client
|
|
243
|
+
user: data.user,
|
|
244
|
+
expiresAt: data.expiresAt ? new Date(data.expiresAt) : void 0
|
|
245
|
+
});
|
|
246
|
+
setUser(data.user);
|
|
247
|
+
onAuthStateChange?.(data.user);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error("Failed to refresh session:", error);
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}, [apiBaseUrl, onAuthStateChange]);
|
|
255
|
+
const loadSession = (0, import_react2.useCallback)(async () => {
|
|
256
|
+
if (isAuthDisabled) {
|
|
257
|
+
setIsLoading(false);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const response = await fetch(
|
|
262
|
+
`${apiBaseUrl}/auth/verify`,
|
|
263
|
+
createFetchOptions("POST")
|
|
264
|
+
);
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
const refreshed = await tryRefreshSession();
|
|
267
|
+
if (!refreshed) {
|
|
268
|
+
clearSession();
|
|
269
|
+
}
|
|
270
|
+
setIsLoading(false);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const userData = await response.json();
|
|
274
|
+
setUser(userData);
|
|
275
|
+
setSession({
|
|
276
|
+
accessToken: "",
|
|
277
|
+
// Not accessible from client
|
|
278
|
+
user: userData
|
|
279
|
+
});
|
|
280
|
+
onAuthStateChange?.(userData);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.error("Failed to load session:", error);
|
|
283
|
+
clearSession();
|
|
284
|
+
} finally {
|
|
285
|
+
setIsLoading(false);
|
|
286
|
+
}
|
|
287
|
+
}, [apiBaseUrl, onAuthStateChange, isAuthDisabled, tryRefreshSession, clearSession]);
|
|
288
|
+
(0, import_react2.useEffect)(() => {
|
|
289
|
+
const fetchCsrfToken = async () => {
|
|
290
|
+
try {
|
|
291
|
+
await fetch(`${apiBaseUrl}/auth/csrf`, {
|
|
292
|
+
credentials: "include"
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error("Failed to fetch CSRF token:", error);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
fetchCsrfToken();
|
|
299
|
+
}, [apiBaseUrl]);
|
|
300
|
+
(0, import_react2.useEffect)(() => {
|
|
301
|
+
loadSession();
|
|
302
|
+
}, [loadSession]);
|
|
303
|
+
const signIn = (0, import_react2.useCallback)(
|
|
304
|
+
async (email, password) => {
|
|
305
|
+
setIsLoading(true);
|
|
306
|
+
try {
|
|
307
|
+
const response = await fetch(
|
|
308
|
+
`${apiBaseUrl}/auth/signin`,
|
|
309
|
+
createFetchOptions("POST", { email, password })
|
|
310
|
+
);
|
|
311
|
+
if (!response.ok) {
|
|
312
|
+
const error = await response.json();
|
|
313
|
+
throw new Error(error.error?.message || error.message || "Sign in failed");
|
|
314
|
+
}
|
|
315
|
+
const data = await response.json();
|
|
316
|
+
setSession({
|
|
317
|
+
accessToken: "",
|
|
318
|
+
// Not accessible from client
|
|
319
|
+
user: data.user,
|
|
320
|
+
expiresAt: data.expiresAt ? new Date(data.expiresAt) : void 0
|
|
321
|
+
});
|
|
322
|
+
setUser(data.user);
|
|
323
|
+
onAuthStateChange?.(data.user);
|
|
324
|
+
} finally {
|
|
325
|
+
setIsLoading(false);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
[apiBaseUrl, onAuthStateChange]
|
|
329
|
+
);
|
|
330
|
+
const signOut = (0, import_react2.useCallback)(async () => {
|
|
331
|
+
setIsLoading(true);
|
|
332
|
+
try {
|
|
333
|
+
await fetch(
|
|
334
|
+
`${apiBaseUrl}/auth/signout`,
|
|
335
|
+
createFetchOptions("POST")
|
|
336
|
+
);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.error("Sign out error:", error);
|
|
339
|
+
} finally {
|
|
340
|
+
clearSession();
|
|
341
|
+
setIsLoading(false);
|
|
342
|
+
}
|
|
343
|
+
}, [apiBaseUrl, clearSession]);
|
|
344
|
+
const refreshSession = (0, import_react2.useCallback)(async () => {
|
|
345
|
+
const response = await fetch(
|
|
346
|
+
`${apiBaseUrl}/auth/refresh`,
|
|
347
|
+
createFetchOptions("POST")
|
|
348
|
+
);
|
|
349
|
+
if (!response.ok) {
|
|
350
|
+
throw new Error("Failed to refresh session");
|
|
351
|
+
}
|
|
352
|
+
const data = await response.json();
|
|
353
|
+
setSession({
|
|
354
|
+
accessToken: "",
|
|
355
|
+
// Not accessible from client
|
|
356
|
+
user: data.user,
|
|
357
|
+
expiresAt: data.expiresAt ? new Date(data.expiresAt) : void 0
|
|
358
|
+
});
|
|
359
|
+
setUser(data.user);
|
|
360
|
+
onAuthStateChange?.(data.user);
|
|
361
|
+
}, [apiBaseUrl, onAuthStateChange]);
|
|
362
|
+
const value = {
|
|
363
|
+
user,
|
|
364
|
+
session,
|
|
365
|
+
isLoading,
|
|
366
|
+
isAuthenticated: isAuthDisabled || !!user,
|
|
367
|
+
signIn,
|
|
368
|
+
signOut,
|
|
369
|
+
refreshSession
|
|
370
|
+
};
|
|
371
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(AuthContext.Provider, { value, children });
|
|
372
|
+
}
|
|
373
|
+
function useAuth() {
|
|
374
|
+
const context = (0, import_react2.useContext)(AuthContext);
|
|
375
|
+
if (!context) {
|
|
376
|
+
throw new Error("useAuth must be used within an AuthProvider");
|
|
377
|
+
}
|
|
378
|
+
return context;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/hooks/use-api-client.ts
|
|
382
|
+
var import_react4 = require("react");
|
|
383
|
+
|
|
384
|
+
// src/hooks/use-admin.ts
|
|
385
|
+
var import_react3 = require("react");
|
|
386
|
+
function useAdmin() {
|
|
387
|
+
const context = (0, import_react3.useContext)(AdminContext);
|
|
388
|
+
if (context === null) {
|
|
389
|
+
throw new Error("useAdmin must be used within an AdminProvider");
|
|
390
|
+
}
|
|
391
|
+
return context;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/hooks/use-api-client.ts
|
|
395
|
+
async function handleResponse(response) {
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
let errorMessage = "An error occurred";
|
|
398
|
+
let errorCode;
|
|
399
|
+
try {
|
|
400
|
+
const errorBody = await response.json();
|
|
401
|
+
if (errorBody.error?.message) {
|
|
402
|
+
errorMessage = errorBody.error.message;
|
|
403
|
+
}
|
|
404
|
+
if (errorBody.error?.code) {
|
|
405
|
+
errorCode = errorBody.error.code;
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
errorMessage = response.statusText || errorMessage;
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
data: null,
|
|
412
|
+
error: {
|
|
413
|
+
message: errorMessage,
|
|
414
|
+
code: errorCode,
|
|
415
|
+
status: response.status
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const data = await response.json();
|
|
421
|
+
return { data, error: null };
|
|
422
|
+
} catch {
|
|
423
|
+
return { data: null, error: null };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function createApiClient(baseUrl) {
|
|
427
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
428
|
+
return {
|
|
429
|
+
async get(path) {
|
|
430
|
+
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
|
431
|
+
method: "GET",
|
|
432
|
+
headers: {
|
|
433
|
+
"Content-Type": "application/json"
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
return handleResponse(response);
|
|
437
|
+
},
|
|
438
|
+
async post(path, body) {
|
|
439
|
+
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers: {
|
|
442
|
+
"Content-Type": "application/json"
|
|
443
|
+
},
|
|
444
|
+
body: JSON.stringify(body)
|
|
445
|
+
});
|
|
446
|
+
return handleResponse(response);
|
|
447
|
+
},
|
|
448
|
+
async put(path, body) {
|
|
449
|
+
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
|
450
|
+
method: "PUT",
|
|
451
|
+
headers: {
|
|
452
|
+
"Content-Type": "application/json"
|
|
453
|
+
},
|
|
454
|
+
body: JSON.stringify(body)
|
|
455
|
+
});
|
|
456
|
+
return handleResponse(response);
|
|
457
|
+
},
|
|
458
|
+
async delete(path) {
|
|
459
|
+
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
|
460
|
+
method: "DELETE",
|
|
461
|
+
headers: {
|
|
462
|
+
"Content-Type": "application/json"
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
return handleResponse(response);
|
|
466
|
+
},
|
|
467
|
+
async upload(path, body) {
|
|
468
|
+
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
|
469
|
+
method: "POST",
|
|
470
|
+
body
|
|
471
|
+
});
|
|
472
|
+
return handleResponse(response);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function useApiClient() {
|
|
477
|
+
const { apiBaseUrl } = useAdmin();
|
|
478
|
+
return (0, import_react4.useMemo)(() => createApiClient(apiBaseUrl), [apiBaseUrl]);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/components/content/navigation-editor.tsx
|
|
482
|
+
var React4 = __toESM(require("react"), 1);
|
|
483
|
+
|
|
484
|
+
// src/components/ui/button.tsx
|
|
485
|
+
var import_class_variance_authority = require("class-variance-authority");
|
|
486
|
+
var React2 = __toESM(require("react"), 1);
|
|
487
|
+
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
488
|
+
var buttonVariants = (0, import_class_variance_authority.cva)(
|
|
489
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
490
|
+
{
|
|
491
|
+
variants: {
|
|
492
|
+
variant: {
|
|
493
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
494
|
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
495
|
+
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
496
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
497
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
498
|
+
link: "text-primary underline-offset-4 hover:underline"
|
|
499
|
+
},
|
|
500
|
+
size: {
|
|
501
|
+
default: "h-10 px-4 py-2",
|
|
502
|
+
sm: "h-9 rounded-md px-3",
|
|
503
|
+
lg: "h-11 rounded-md px-8",
|
|
504
|
+
icon: "h-10 w-10"
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
defaultVariants: {
|
|
508
|
+
variant: "default",
|
|
509
|
+
size: "default"
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
var Button = React2.forwardRef(
|
|
514
|
+
({ className, variant, size, ...props }, ref) => {
|
|
515
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { className: cn(buttonVariants({ variant, size, className })), ref, ...props });
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
Button.displayName = "Button";
|
|
519
|
+
|
|
520
|
+
// src/components/ui/input.tsx
|
|
521
|
+
var React3 = __toESM(require("react"), 1);
|
|
522
|
+
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
523
|
+
var Input = React3.forwardRef(
|
|
524
|
+
({ className, type, ...props }, ref) => {
|
|
525
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
526
|
+
"input",
|
|
527
|
+
{
|
|
528
|
+
type,
|
|
529
|
+
className: cn(
|
|
530
|
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
531
|
+
className
|
|
532
|
+
),
|
|
533
|
+
ref,
|
|
534
|
+
...props
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
);
|
|
539
|
+
Input.displayName = "Input";
|
|
540
|
+
|
|
541
|
+
// src/components/content/navigation-editor.tsx
|
|
542
|
+
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
543
|
+
function NavigationEditor({ items: initialItems, onSave, className }) {
|
|
544
|
+
const [items, setItems] = React4.useState(initialItems);
|
|
545
|
+
const handleAddItem = () => {
|
|
546
|
+
setItems([...items, { label: "", href: "", children: [] }]);
|
|
547
|
+
};
|
|
548
|
+
const handleRemoveItem = (index) => {
|
|
549
|
+
const newItems = [...items];
|
|
550
|
+
newItems.splice(index, 1);
|
|
551
|
+
setItems(newItems);
|
|
552
|
+
};
|
|
553
|
+
const handleItemChange = (index, field, value) => {
|
|
554
|
+
const newItems = [...items];
|
|
555
|
+
const item = newItems[index];
|
|
556
|
+
if (item) {
|
|
557
|
+
newItems[index] = { ...item, [field]: value };
|
|
558
|
+
}
|
|
559
|
+
setItems(newItems);
|
|
560
|
+
};
|
|
561
|
+
const handleAddChild = (parentIndex) => {
|
|
562
|
+
const newItems = [...items];
|
|
563
|
+
const parent = newItems[parentIndex];
|
|
564
|
+
if (parent) {
|
|
565
|
+
newItems[parentIndex] = {
|
|
566
|
+
...parent,
|
|
567
|
+
children: [...parent.children ?? [], { label: "", href: "" }]
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
setItems(newItems);
|
|
571
|
+
};
|
|
572
|
+
const handleRemoveChild = (parentIndex, childIndex) => {
|
|
573
|
+
const newItems = [...items];
|
|
574
|
+
const parent = newItems[parentIndex];
|
|
575
|
+
if (parent?.children) {
|
|
576
|
+
const newChildren = [...parent.children];
|
|
577
|
+
newChildren.splice(childIndex, 1);
|
|
578
|
+
newItems[parentIndex] = { ...parent, children: newChildren };
|
|
579
|
+
}
|
|
580
|
+
setItems(newItems);
|
|
581
|
+
};
|
|
582
|
+
const handleChildChange = (parentIndex, childIndex, field, value) => {
|
|
583
|
+
const newItems = [...items];
|
|
584
|
+
const parent = newItems[parentIndex];
|
|
585
|
+
if (parent?.children) {
|
|
586
|
+
const newChildren = [...parent.children];
|
|
587
|
+
const child = newChildren[childIndex];
|
|
588
|
+
if (child) {
|
|
589
|
+
newChildren[childIndex] = { ...child, [field]: value };
|
|
590
|
+
newItems[parentIndex] = { ...parent, children: newChildren };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
setItems(newItems);
|
|
594
|
+
};
|
|
595
|
+
const handleSave = () => {
|
|
596
|
+
onSave(items);
|
|
597
|
+
};
|
|
598
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: cn("space-y-4", className), "data-testid": "navigation-editor", children: [
|
|
599
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "flex items-center justify-between", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("h2", { className: "text-xl font-semibold", children: "Navigation" }) }),
|
|
600
|
+
items.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "text-sm text-muted-foreground text-center py-8", "data-testid": "empty-state", children: "No navigation items yet." }) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "space-y-3", children: items.map((item, index) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
601
|
+
"div",
|
|
602
|
+
{
|
|
603
|
+
className: "rounded-md border border-input bg-background p-4 space-y-3",
|
|
604
|
+
"data-testid": `nav-item-${index}`,
|
|
605
|
+
children: [
|
|
606
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex gap-2 items-start", children: [
|
|
607
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex-1 space-y-2", children: [
|
|
608
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
609
|
+
Input,
|
|
610
|
+
{
|
|
611
|
+
placeholder: "Label",
|
|
612
|
+
value: item.label,
|
|
613
|
+
onChange: (e) => handleItemChange(index, "label", e.target.value),
|
|
614
|
+
"data-testid": `nav-item-label-${index}`
|
|
615
|
+
}
|
|
616
|
+
),
|
|
617
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
618
|
+
Input,
|
|
619
|
+
{
|
|
620
|
+
placeholder: "URL (e.g. /about)",
|
|
621
|
+
value: item.href,
|
|
622
|
+
onChange: (e) => handleItemChange(index, "href", e.target.value),
|
|
623
|
+
"data-testid": `nav-item-href-${index}`
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
] }),
|
|
627
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
628
|
+
Button,
|
|
629
|
+
{
|
|
630
|
+
type: "button",
|
|
631
|
+
variant: "ghost",
|
|
632
|
+
size: "icon",
|
|
633
|
+
onClick: () => handleRemoveItem(index),
|
|
634
|
+
title: "Remove item",
|
|
635
|
+
"data-testid": `nav-item-remove-${index}`,
|
|
636
|
+
children: "\u2715"
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
] }),
|
|
640
|
+
(item.children ?? []).length > 0 && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "ml-6 space-y-2", children: (item.children ?? []).map((child, childIndex) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
641
|
+
"div",
|
|
642
|
+
{
|
|
643
|
+
className: "flex gap-2 items-start rounded-md border border-input bg-muted/30 p-3",
|
|
644
|
+
"data-testid": `nav-child-${index}-${childIndex}`,
|
|
645
|
+
children: [
|
|
646
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex-1 space-y-2", children: [
|
|
647
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
648
|
+
Input,
|
|
649
|
+
{
|
|
650
|
+
placeholder: "Label",
|
|
651
|
+
value: child.label,
|
|
652
|
+
onChange: (e) => handleChildChange(index, childIndex, "label", e.target.value),
|
|
653
|
+
"data-testid": `nav-child-label-${index}-${childIndex}`
|
|
654
|
+
}
|
|
655
|
+
),
|
|
656
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
657
|
+
Input,
|
|
658
|
+
{
|
|
659
|
+
placeholder: "URL",
|
|
660
|
+
value: child.href,
|
|
661
|
+
onChange: (e) => handleChildChange(index, childIndex, "href", e.target.value),
|
|
662
|
+
"data-testid": `nav-child-href-${index}-${childIndex}`
|
|
663
|
+
}
|
|
664
|
+
)
|
|
665
|
+
] }),
|
|
666
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
667
|
+
Button,
|
|
668
|
+
{
|
|
669
|
+
type: "button",
|
|
670
|
+
variant: "ghost",
|
|
671
|
+
size: "icon",
|
|
672
|
+
onClick: () => handleRemoveChild(index, childIndex),
|
|
673
|
+
title: "Remove child",
|
|
674
|
+
"data-testid": `nav-child-remove-${index}-${childIndex}`,
|
|
675
|
+
children: "\u2715"
|
|
676
|
+
}
|
|
677
|
+
)
|
|
678
|
+
]
|
|
679
|
+
},
|
|
680
|
+
childIndex
|
|
681
|
+
)) }),
|
|
682
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
683
|
+
Button,
|
|
684
|
+
{
|
|
685
|
+
type: "button",
|
|
686
|
+
variant: "outline",
|
|
687
|
+
size: "sm",
|
|
688
|
+
onClick: () => handleAddChild(index),
|
|
689
|
+
"data-testid": `nav-add-child-${index}`,
|
|
690
|
+
children: "Add Child"
|
|
691
|
+
}
|
|
692
|
+
)
|
|
693
|
+
]
|
|
694
|
+
},
|
|
695
|
+
index
|
|
696
|
+
)) }),
|
|
697
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex gap-2 border-t border-input pt-4", children: [
|
|
698
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Button, { type: "button", variant: "outline", onClick: handleAddItem, "data-testid": "nav-add-item", children: "Add Item" }),
|
|
699
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Button, { type: "button", onClick: handleSave, "data-testid": "nav-save", children: "Save Navigation" })
|
|
700
|
+
] })
|
|
701
|
+
] });
|
|
702
|
+
}
|
|
703
|
+
NavigationEditor.displayName = "NavigationEditor";
|
|
704
|
+
|
|
705
|
+
// src/components/content/page-list.tsx
|
|
706
|
+
var React5 = __toESM(require("react"), 1);
|
|
707
|
+
var import_jsx_runtime7 = require("react/jsx-runtime");
|
|
708
|
+
function PageList({ onSelectPage, onCreatePage, className }) {
|
|
709
|
+
const api = useApiClient();
|
|
710
|
+
const { registry } = useAdmin();
|
|
711
|
+
const [pages, setPages] = React5.useState([]);
|
|
712
|
+
const [loading, setLoading] = React5.useState(true);
|
|
713
|
+
const [error, setError] = React5.useState(null);
|
|
714
|
+
const [search, setSearch] = React5.useState("");
|
|
715
|
+
const [pageTypeFilter, setPageTypeFilter] = React5.useState("");
|
|
716
|
+
const pageTypes = registry.getAllPageTypes();
|
|
717
|
+
React5.useEffect(() => {
|
|
718
|
+
let cancelled = false;
|
|
719
|
+
async function fetchPages() {
|
|
720
|
+
setLoading(true);
|
|
721
|
+
setError(null);
|
|
722
|
+
const result = await api.get("/pages");
|
|
723
|
+
if (cancelled) return;
|
|
724
|
+
if (result.error) {
|
|
725
|
+
setError(result.error.message);
|
|
726
|
+
setLoading(false);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
setPages(result.data ?? []);
|
|
730
|
+
setLoading(false);
|
|
731
|
+
}
|
|
732
|
+
void fetchPages();
|
|
733
|
+
return () => {
|
|
734
|
+
cancelled = true;
|
|
735
|
+
};
|
|
736
|
+
}, [api]);
|
|
737
|
+
const filteredPages = React5.useMemo(() => {
|
|
738
|
+
let result = pages;
|
|
739
|
+
if (search) {
|
|
740
|
+
const lowerSearch = search.toLowerCase();
|
|
741
|
+
result = result.filter(
|
|
742
|
+
(page) => page.title.toLowerCase().includes(lowerSearch) || page.slug.toLowerCase().includes(lowerSearch)
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
if (pageTypeFilter) {
|
|
746
|
+
result = result.filter((page) => page.pageType === pageTypeFilter);
|
|
747
|
+
}
|
|
748
|
+
return result;
|
|
749
|
+
}, [pages, search, pageTypeFilter]);
|
|
750
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: cn("space-y-4", className), "data-testid": "page-list", children: [
|
|
751
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center justify-between", children: [
|
|
752
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("h2", { className: "text-xl font-semibold", children: "Pages" }),
|
|
753
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Button, { type: "button", onClick: onCreatePage, "data-testid": "create-page", children: "Create New Page" })
|
|
754
|
+
] }),
|
|
755
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex gap-2", children: [
|
|
756
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
757
|
+
Input,
|
|
758
|
+
{
|
|
759
|
+
placeholder: "Search by title or slug...",
|
|
760
|
+
value: search,
|
|
761
|
+
onChange: (e) => setSearch(e.target.value),
|
|
762
|
+
className: "max-w-sm",
|
|
763
|
+
"data-testid": "search-input"
|
|
764
|
+
}
|
|
765
|
+
),
|
|
766
|
+
pageTypes.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
|
|
767
|
+
"select",
|
|
768
|
+
{
|
|
769
|
+
value: pageTypeFilter,
|
|
770
|
+
onChange: (e) => setPageTypeFilter(e.target.value),
|
|
771
|
+
className: "flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm",
|
|
772
|
+
"data-testid": "page-type-filter",
|
|
773
|
+
children: [
|
|
774
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "", children: "All Types" }),
|
|
775
|
+
pageTypes.map((pt) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: pt.name, children: pt.name }, pt.name))
|
|
776
|
+
]
|
|
777
|
+
}
|
|
778
|
+
)
|
|
779
|
+
] }),
|
|
780
|
+
loading && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "text-sm text-muted-foreground", "data-testid": "loading", children: "Loading pages..." }),
|
|
781
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "text-sm text-destructive", "data-testid": "error", children: error }),
|
|
782
|
+
!loading && !error && filteredPages.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "text-sm text-muted-foreground text-center py-8", "data-testid": "empty-state", children: pages.length === 0 ? "No pages yet. Create your first page." : "No pages match your search." }),
|
|
783
|
+
!loading && !error && filteredPages.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "rounded-md border border-input", "data-testid": "page-table", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("table", { className: "w-full text-sm", children: [
|
|
784
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("tr", { className: "border-b border-input bg-muted/50", children: [
|
|
785
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("th", { className: "text-left p-3 font-medium", children: "Title" }),
|
|
786
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("th", { className: "text-left p-3 font-medium", children: "Slug" }),
|
|
787
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("th", { className: "text-left p-3 font-medium", children: "Type" })
|
|
788
|
+
] }) }),
|
|
789
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("tbody", { children: filteredPages.map((page) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
|
|
790
|
+
"tr",
|
|
791
|
+
{
|
|
792
|
+
className: "border-b border-input last:border-0 hover:bg-muted/30 cursor-pointer",
|
|
793
|
+
onClick: () => onSelectPage(page),
|
|
794
|
+
onKeyDown: (e) => {
|
|
795
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
796
|
+
e.preventDefault();
|
|
797
|
+
onSelectPage(page);
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
tabIndex: 0,
|
|
801
|
+
"data-testid": `page-row-${page.id}`,
|
|
802
|
+
children: [
|
|
803
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("td", { className: "p-3", children: page.title }),
|
|
804
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("td", { className: "p-3 text-muted-foreground", children: page.slug }),
|
|
805
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("td", { className: "p-3 text-muted-foreground capitalize", children: page.pageType })
|
|
806
|
+
]
|
|
807
|
+
},
|
|
808
|
+
page.id
|
|
809
|
+
)) })
|
|
810
|
+
] }) })
|
|
811
|
+
] });
|
|
812
|
+
}
|
|
813
|
+
PageList.displayName = "PageList";
|
|
814
|
+
|
|
815
|
+
// src/components/ui/error-boundary.tsx
|
|
816
|
+
var React6 = __toESM(require("react"), 1);
|
|
817
|
+
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
818
|
+
var ErrorBoundary = class extends React6.Component {
|
|
819
|
+
constructor(props) {
|
|
820
|
+
super(props);
|
|
821
|
+
this.state = { hasError: false, error: null };
|
|
822
|
+
}
|
|
823
|
+
static getDerivedStateFromError(error) {
|
|
824
|
+
return { hasError: true, error };
|
|
825
|
+
}
|
|
826
|
+
handleReset = () => {
|
|
827
|
+
this.setState({ hasError: false, error: null });
|
|
828
|
+
this.props.onReset?.();
|
|
829
|
+
};
|
|
830
|
+
render() {
|
|
831
|
+
if (this.state.hasError) {
|
|
832
|
+
if (this.props.fallback) {
|
|
833
|
+
return this.props.fallback;
|
|
834
|
+
}
|
|
835
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
|
|
836
|
+
"div",
|
|
837
|
+
{
|
|
838
|
+
className: cn(
|
|
839
|
+
"rounded-md border border-destructive bg-destructive/10 p-4",
|
|
840
|
+
this.props.className
|
|
841
|
+
),
|
|
842
|
+
role: "alert",
|
|
843
|
+
"data-testid": "error-boundary",
|
|
844
|
+
children: [
|
|
845
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("h3", { className: "text-sm font-semibold text-destructive mb-1", children: "Something went wrong" }),
|
|
846
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { className: "text-sm text-muted-foreground", children: this.state.error?.message ?? "An unexpected error occurred" }),
|
|
847
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
848
|
+
Button,
|
|
849
|
+
{
|
|
850
|
+
type: "button",
|
|
851
|
+
variant: "outline",
|
|
852
|
+
size: "sm",
|
|
853
|
+
className: "mt-3",
|
|
854
|
+
onClick: this.handleReset,
|
|
855
|
+
"data-testid": "error-boundary-retry",
|
|
856
|
+
children: "Retry"
|
|
857
|
+
}
|
|
858
|
+
)
|
|
859
|
+
]
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
return this.props.children;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// src/components/dashboard/kpi-cards.tsx
|
|
868
|
+
var React7 = __toESM(require("react"), 1);
|
|
869
|
+
|
|
870
|
+
// src/components/ui/skeleton.tsx
|
|
871
|
+
var import_jsx_runtime9 = require("react/jsx-runtime");
|
|
872
|
+
function Skeleton({ className, width, height, style, ...props }) {
|
|
873
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
874
|
+
"div",
|
|
875
|
+
{
|
|
876
|
+
className: cn("animate-pulse rounded-md bg-muted", className),
|
|
877
|
+
style: { width, height, ...style },
|
|
878
|
+
"data-testid": "skeleton",
|
|
879
|
+
...props
|
|
880
|
+
}
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
Skeleton.displayName = "Skeleton";
|
|
884
|
+
|
|
885
|
+
// src/components/dashboard/kpi-cards.tsx
|
|
886
|
+
var import_jsx_runtime10 = require("react/jsx-runtime");
|
|
887
|
+
function KpiCards({ className }) {
|
|
888
|
+
const api = useApiClient();
|
|
889
|
+
const { registry } = useAdmin();
|
|
890
|
+
const [pages, setPages] = React7.useState({ value: null, loading: true, error: null });
|
|
891
|
+
const [media, setMedia] = React7.useState({ value: null, loading: true, error: null });
|
|
892
|
+
const [navigation, setNavigation] = React7.useState({
|
|
893
|
+
value: null,
|
|
894
|
+
loading: true,
|
|
895
|
+
error: null
|
|
896
|
+
});
|
|
897
|
+
const sectionsCount = registry.getAllSections().length;
|
|
898
|
+
const fetchPages = React7.useCallback(
|
|
899
|
+
async (signal) => {
|
|
900
|
+
setPages({ value: null, loading: true, error: null });
|
|
901
|
+
const result = await api.get("/pages");
|
|
902
|
+
if (signal?.cancelled) return;
|
|
903
|
+
if (result.error) {
|
|
904
|
+
setPages({ value: null, loading: false, error: result.error.message });
|
|
905
|
+
} else {
|
|
906
|
+
setPages({ value: result.data?.length ?? 0, loading: false, error: null });
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
[api]
|
|
910
|
+
);
|
|
911
|
+
const fetchMedia = React7.useCallback(
|
|
912
|
+
async (signal) => {
|
|
913
|
+
setMedia({ value: null, loading: true, error: null });
|
|
914
|
+
const result = await api.get("/media");
|
|
915
|
+
if (signal?.cancelled) return;
|
|
916
|
+
if (result.error) {
|
|
917
|
+
setMedia({ value: null, loading: false, error: result.error.message });
|
|
918
|
+
} else {
|
|
919
|
+
setMedia({ value: result.data?.length ?? 0, loading: false, error: null });
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
[api]
|
|
923
|
+
);
|
|
924
|
+
const fetchNavigation = React7.useCallback(
|
|
925
|
+
async (signal) => {
|
|
926
|
+
setNavigation({ value: null, loading: true, error: null });
|
|
927
|
+
const result = await api.get("/navigation");
|
|
928
|
+
if (signal?.cancelled) return;
|
|
929
|
+
if (result.error) {
|
|
930
|
+
setNavigation({ value: null, loading: false, error: result.error.message });
|
|
931
|
+
} else {
|
|
932
|
+
setNavigation({ value: result.data?.length ?? 0, loading: false, error: null });
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
[api]
|
|
936
|
+
);
|
|
937
|
+
React7.useEffect(() => {
|
|
938
|
+
const signal = { cancelled: false };
|
|
939
|
+
void Promise.allSettled([fetchPages(signal), fetchMedia(signal), fetchNavigation(signal)]);
|
|
940
|
+
return () => {
|
|
941
|
+
signal.cancelled = true;
|
|
942
|
+
};
|
|
943
|
+
}, [fetchPages, fetchMedia, fetchNavigation]);
|
|
944
|
+
const kpis = [
|
|
945
|
+
{ label: "Pages", state: pages, onRetry: () => void fetchPages(), testId: "kpi-pages" },
|
|
946
|
+
{ label: "Media Files", state: media, onRetry: () => void fetchMedia(), testId: "kpi-media" },
|
|
947
|
+
{
|
|
948
|
+
label: "Navigation Sets",
|
|
949
|
+
state: navigation,
|
|
950
|
+
onRetry: () => void fetchNavigation(),
|
|
951
|
+
testId: "kpi-navigation"
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
label: "Sections",
|
|
955
|
+
state: { value: sectionsCount, loading: false, error: null },
|
|
956
|
+
onRetry: void 0,
|
|
957
|
+
testId: "kpi-sections"
|
|
958
|
+
}
|
|
959
|
+
];
|
|
960
|
+
return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("div", { className: cn("grid grid-cols-2 gap-4 md:grid-cols-4", className), "data-testid": "kpi-cards", children: kpis.map((kpi) => /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
|
|
961
|
+
"div",
|
|
962
|
+
{
|
|
963
|
+
className: "rounded-lg border border-input bg-background p-4",
|
|
964
|
+
"data-testid": kpi.testId,
|
|
965
|
+
children: [
|
|
966
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: "text-sm text-muted-foreground", children: kpi.label }),
|
|
967
|
+
kpi.state.loading && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Skeleton, { className: "mt-2 h-8 w-16", "data-testid": `${kpi.testId}-skeleton` }),
|
|
968
|
+
!kpi.state.loading && kpi.state.error && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: "mt-1", children: [
|
|
969
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: "text-sm text-destructive", "data-testid": `${kpi.testId}-error`, children: "Error loading" }),
|
|
970
|
+
kpi.onRetry && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
971
|
+
Button,
|
|
972
|
+
{
|
|
973
|
+
type: "button",
|
|
974
|
+
variant: "outline",
|
|
975
|
+
size: "sm",
|
|
976
|
+
className: "mt-2",
|
|
977
|
+
onClick: kpi.onRetry,
|
|
978
|
+
"data-testid": `${kpi.testId}-retry`,
|
|
979
|
+
children: "Retry"
|
|
980
|
+
}
|
|
981
|
+
)
|
|
982
|
+
] }),
|
|
983
|
+
!kpi.state.loading && !kpi.state.error && kpi.state.value !== null && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: "mt-1 text-2xl font-bold", "data-testid": `${kpi.testId}-value`, children: kpi.state.value })
|
|
984
|
+
]
|
|
985
|
+
},
|
|
986
|
+
kpi.testId
|
|
987
|
+
)) });
|
|
988
|
+
}
|
|
989
|
+
KpiCards.displayName = "KpiCards";
|
|
990
|
+
|
|
991
|
+
// src/components/dashboard/quick-actions.tsx
|
|
992
|
+
var import_jsx_runtime11 = require("react/jsx-runtime");
|
|
993
|
+
function QuickActions({ onCreatePage, onUploadMedia, className }) {
|
|
994
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: cn("space-y-3", className), "data-testid": "quick-actions", children: [
|
|
995
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("h2", { className: "text-lg font-semibold", children: "Quick Actions" }),
|
|
996
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex flex-wrap gap-3", children: [
|
|
997
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
|
|
998
|
+
Button,
|
|
999
|
+
{
|
|
1000
|
+
onClick: onCreatePage,
|
|
1001
|
+
"aria-label": "Create New Page",
|
|
1002
|
+
"data-testid": "quick-action-create-page",
|
|
1003
|
+
children: "Create New Page"
|
|
1004
|
+
}
|
|
1005
|
+
),
|
|
1006
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
|
|
1007
|
+
Button,
|
|
1008
|
+
{
|
|
1009
|
+
variant: "outline",
|
|
1010
|
+
onClick: onUploadMedia,
|
|
1011
|
+
"aria-label": "Upload Media",
|
|
1012
|
+
"data-testid": "quick-action-upload-media",
|
|
1013
|
+
children: "Upload Media"
|
|
1014
|
+
}
|
|
1015
|
+
)
|
|
1016
|
+
] })
|
|
1017
|
+
] });
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/components/dashboard/recent-pages.tsx
|
|
1021
|
+
var React8 = __toESM(require("react"), 1);
|
|
1022
|
+
var import_jsx_runtime12 = require("react/jsx-runtime");
|
|
1023
|
+
function formatTimestamp(dateString) {
|
|
1024
|
+
try {
|
|
1025
|
+
return new Date(dateString).toLocaleDateString(void 0, {
|
|
1026
|
+
year: "numeric",
|
|
1027
|
+
month: "short",
|
|
1028
|
+
day: "numeric"
|
|
1029
|
+
});
|
|
1030
|
+
} catch {
|
|
1031
|
+
return dateString;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
function RecentPages({ onSelectPage, className }) {
|
|
1035
|
+
const api = useApiClient();
|
|
1036
|
+
const [pages, setPages] = React8.useState([]);
|
|
1037
|
+
const [loading, setLoading] = React8.useState(true);
|
|
1038
|
+
const [error, setError] = React8.useState(false);
|
|
1039
|
+
const fetchPages = React8.useCallback(
|
|
1040
|
+
async (signal) => {
|
|
1041
|
+
setLoading(true);
|
|
1042
|
+
setError(false);
|
|
1043
|
+
const result = await api.get("/pages");
|
|
1044
|
+
if (signal?.cancelled) return;
|
|
1045
|
+
if (result.error) {
|
|
1046
|
+
setError(true);
|
|
1047
|
+
setLoading(false);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const allPages = result.data ?? [];
|
|
1051
|
+
const sorted = [...allPages].sort((a, b) => {
|
|
1052
|
+
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
1053
|
+
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
1054
|
+
return dateB - dateA;
|
|
1055
|
+
});
|
|
1056
|
+
setPages(sorted.slice(0, 10));
|
|
1057
|
+
setLoading(false);
|
|
1058
|
+
},
|
|
1059
|
+
[api]
|
|
1060
|
+
);
|
|
1061
|
+
React8.useEffect(() => {
|
|
1062
|
+
const signal = { cancelled: false };
|
|
1063
|
+
void fetchPages(signal);
|
|
1064
|
+
return () => {
|
|
1065
|
+
signal.cancelled = true;
|
|
1066
|
+
};
|
|
1067
|
+
}, [fetchPages]);
|
|
1068
|
+
return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: cn("space-y-3", className), "data-testid": "recent-pages", children: [
|
|
1069
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("h2", { className: "text-lg font-semibold", children: "Recent Pages" }),
|
|
1070
|
+
loading && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "space-y-2", "data-testid": "recent-pages-loading", children: Array.from({ length: 5 }).map((_, i) => (
|
|
1071
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: Temporary loading skeletons with no stable data
|
|
1072
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Skeleton, { className: "h-10 w-full" }, i)
|
|
1073
|
+
)) }),
|
|
1074
|
+
!loading && error && /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { "data-testid": "recent-pages-error", children: [
|
|
1075
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "text-sm text-destructive", children: "Unable to load recent pages" }),
|
|
1076
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
|
|
1077
|
+
Button,
|
|
1078
|
+
{
|
|
1079
|
+
type: "button",
|
|
1080
|
+
variant: "outline",
|
|
1081
|
+
size: "sm",
|
|
1082
|
+
className: "mt-2",
|
|
1083
|
+
onClick: () => void fetchPages(),
|
|
1084
|
+
"data-testid": "recent-pages-retry",
|
|
1085
|
+
children: "Retry"
|
|
1086
|
+
}
|
|
1087
|
+
)
|
|
1088
|
+
] }),
|
|
1089
|
+
!loading && !error && pages.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "text-sm text-muted-foreground py-4", "data-testid": "recent-pages-empty", children: "No pages yet." }),
|
|
1090
|
+
!loading && !error && pages.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "rounded-md border border-input", "data-testid": "recent-pages-list", children: pages.map((page) => /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
|
|
1091
|
+
"button",
|
|
1092
|
+
{
|
|
1093
|
+
type: "button",
|
|
1094
|
+
className: "flex items-center justify-between border-b border-input last:border-0 px-3 py-2 hover:bg-muted/30 cursor-pointer w-full text-left",
|
|
1095
|
+
onClick: () => onSelectPage(page),
|
|
1096
|
+
"data-testid": `recent-page-${page.id}`,
|
|
1097
|
+
children: [
|
|
1098
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "min-w-0 flex-1", children: [
|
|
1099
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "text-sm font-medium truncate", children: page.title }),
|
|
1100
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("p", { className: "text-xs text-muted-foreground truncate", children: page.slug })
|
|
1101
|
+
] }),
|
|
1102
|
+
page.updatedAt && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "ml-4 text-xs text-muted-foreground whitespace-nowrap", children: formatTimestamp(page.updatedAt) })
|
|
1103
|
+
]
|
|
1104
|
+
},
|
|
1105
|
+
page.id
|
|
1106
|
+
)) })
|
|
1107
|
+
] });
|
|
1108
|
+
}
|
|
1109
|
+
RecentPages.displayName = "RecentPages";
|
|
1110
|
+
|
|
1111
|
+
// src/components/dashboard/dashboard-page.tsx
|
|
1112
|
+
var import_jsx_runtime13 = require("react/jsx-runtime");
|
|
1113
|
+
function DashboardPage({
|
|
1114
|
+
onSelectPage,
|
|
1115
|
+
onCreatePage,
|
|
1116
|
+
onUploadMedia,
|
|
1117
|
+
className
|
|
1118
|
+
}) {
|
|
1119
|
+
return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: cn("space-y-6", className), "data-testid": "dashboard-page", children: [
|
|
1120
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("h1", { className: "text-2xl font-bold", children: "Dashboard" }),
|
|
1121
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ErrorBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(KpiCards, {}) }),
|
|
1122
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "grid gap-6 md:grid-cols-3", children: [
|
|
1123
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "md:col-span-2", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ErrorBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(RecentPages, { onSelectPage }) }) }),
|
|
1124
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ErrorBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(QuickActions, { onCreatePage, onUploadMedia }) }) })
|
|
1125
|
+
] })
|
|
1126
|
+
] });
|
|
1127
|
+
}
|
|
1128
|
+
DashboardPage.displayName = "DashboardPage";
|
|
1129
|
+
|
|
1130
|
+
// src/components/editors/page-editor.tsx
|
|
1131
|
+
var React20 = __toESM(require("react"), 1);
|
|
1132
|
+
|
|
1133
|
+
// src/lib/form-generator.tsx
|
|
1134
|
+
var import_zod = require("@hookform/resolvers/zod");
|
|
1135
|
+
var import_core = require("@structcms/core");
|
|
1136
|
+
var React19 = __toESM(require("react"), 1);
|
|
1137
|
+
var import_react_hook_form = require("react-hook-form");
|
|
1138
|
+
|
|
1139
|
+
// src/components/inputs/array-field.tsx
|
|
1140
|
+
var React10 = __toESM(require("react"), 1);
|
|
1141
|
+
|
|
1142
|
+
// src/components/ui/label.tsx
|
|
1143
|
+
var import_class_variance_authority2 = require("class-variance-authority");
|
|
1144
|
+
var React9 = __toESM(require("react"), 1);
|
|
1145
|
+
var import_jsx_runtime14 = (
|
|
1146
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: Generic label component, control association handled by consumers
|
|
1147
|
+
require("react/jsx-runtime")
|
|
1148
|
+
);
|
|
1149
|
+
var labelVariants = (0, import_class_variance_authority2.cva)(
|
|
1150
|
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
1151
|
+
);
|
|
1152
|
+
var Label = React9.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("label", { ref, className: cn(labelVariants(), className), ...props }));
|
|
1153
|
+
Label.displayName = "Label";
|
|
1154
|
+
|
|
1155
|
+
// src/components/inputs/array-field.tsx
|
|
1156
|
+
var import_jsx_runtime15 = require("react/jsx-runtime");
|
|
1157
|
+
function ArrayFieldInner({
|
|
1158
|
+
label,
|
|
1159
|
+
value,
|
|
1160
|
+
onChange,
|
|
1161
|
+
renderItem,
|
|
1162
|
+
createDefaultItem,
|
|
1163
|
+
error,
|
|
1164
|
+
required,
|
|
1165
|
+
className,
|
|
1166
|
+
id,
|
|
1167
|
+
name
|
|
1168
|
+
}, ref) {
|
|
1169
|
+
const inputId = id || name || React10.useId();
|
|
1170
|
+
const handleAdd = () => {
|
|
1171
|
+
onChange([...value, createDefaultItem()]);
|
|
1172
|
+
};
|
|
1173
|
+
const handleRemove = (index) => {
|
|
1174
|
+
const newValue = [...value];
|
|
1175
|
+
newValue.splice(index, 1);
|
|
1176
|
+
onChange(newValue);
|
|
1177
|
+
};
|
|
1178
|
+
const handleMoveUp = (index) => {
|
|
1179
|
+
if (index === 0) return;
|
|
1180
|
+
const newValue = [...value];
|
|
1181
|
+
const temp = newValue[index];
|
|
1182
|
+
newValue[index] = newValue[index - 1];
|
|
1183
|
+
newValue[index - 1] = temp;
|
|
1184
|
+
onChange(newValue);
|
|
1185
|
+
};
|
|
1186
|
+
const handleMoveDown = (index) => {
|
|
1187
|
+
if (index === value.length - 1) return;
|
|
1188
|
+
const newValue = [...value];
|
|
1189
|
+
const temp = newValue[index];
|
|
1190
|
+
newValue[index] = newValue[index + 1];
|
|
1191
|
+
newValue[index + 1] = temp;
|
|
1192
|
+
onChange(newValue);
|
|
1193
|
+
};
|
|
1194
|
+
const handleItemChange = (index, item) => {
|
|
1195
|
+
const newValue = [...value];
|
|
1196
|
+
newValue[index] = item;
|
|
1197
|
+
onChange(newValue);
|
|
1198
|
+
};
|
|
1199
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { ref, className: cn("space-y-2", className), "data-testid": "array-field", children: [
|
|
1200
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(Label, { htmlFor: inputId, children: [
|
|
1201
|
+
label,
|
|
1202
|
+
required && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-destructive ml-1", children: "*" })
|
|
1203
|
+
] }),
|
|
1204
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
|
|
1205
|
+
"div",
|
|
1206
|
+
{
|
|
1207
|
+
className: cn(
|
|
1208
|
+
"rounded-md border border-input bg-background p-4",
|
|
1209
|
+
error && "border-destructive"
|
|
1210
|
+
),
|
|
1211
|
+
children: [
|
|
1212
|
+
value.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { className: "text-sm text-muted-foreground text-center py-4", children: "No items yet" }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "space-y-3", children: value.map((item, index) => /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
|
|
1213
|
+
"div",
|
|
1214
|
+
{
|
|
1215
|
+
className: "flex gap-2 items-start p-3 rounded-md border border-input bg-muted/50",
|
|
1216
|
+
"data-testid": `array-item-${index}`,
|
|
1217
|
+
children: [
|
|
1218
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "flex-1", children: renderItem(item, index, (newItem) => handleItemChange(index, newItem)) }),
|
|
1219
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex flex-col gap-1", children: [
|
|
1220
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1221
|
+
Button,
|
|
1222
|
+
{
|
|
1223
|
+
type: "button",
|
|
1224
|
+
variant: "ghost",
|
|
1225
|
+
size: "icon",
|
|
1226
|
+
onClick: () => handleMoveUp(index),
|
|
1227
|
+
disabled: index === 0,
|
|
1228
|
+
title: "Move up",
|
|
1229
|
+
"data-testid": `move-up-${index}`,
|
|
1230
|
+
children: "\u2191"
|
|
1231
|
+
}
|
|
1232
|
+
),
|
|
1233
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1234
|
+
Button,
|
|
1235
|
+
{
|
|
1236
|
+
type: "button",
|
|
1237
|
+
variant: "ghost",
|
|
1238
|
+
size: "icon",
|
|
1239
|
+
onClick: () => handleMoveDown(index),
|
|
1240
|
+
disabled: index === value.length - 1,
|
|
1241
|
+
title: "Move down",
|
|
1242
|
+
"data-testid": `move-down-${index}`,
|
|
1243
|
+
children: "\u2193"
|
|
1244
|
+
}
|
|
1245
|
+
),
|
|
1246
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1247
|
+
Button,
|
|
1248
|
+
{
|
|
1249
|
+
type: "button",
|
|
1250
|
+
variant: "ghost",
|
|
1251
|
+
size: "icon",
|
|
1252
|
+
onClick: () => handleRemove(index),
|
|
1253
|
+
title: "Remove",
|
|
1254
|
+
"data-testid": `remove-${index}`,
|
|
1255
|
+
children: "\u2715"
|
|
1256
|
+
}
|
|
1257
|
+
)
|
|
1258
|
+
] })
|
|
1259
|
+
]
|
|
1260
|
+
},
|
|
1261
|
+
index
|
|
1262
|
+
)) }),
|
|
1263
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "mt-4", children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1264
|
+
Button,
|
|
1265
|
+
{
|
|
1266
|
+
type: "button",
|
|
1267
|
+
variant: "outline",
|
|
1268
|
+
onClick: handleAdd,
|
|
1269
|
+
id: inputId,
|
|
1270
|
+
"data-testid": "add-item",
|
|
1271
|
+
children: "Add Item"
|
|
1272
|
+
}
|
|
1273
|
+
) })
|
|
1274
|
+
]
|
|
1275
|
+
}
|
|
1276
|
+
),
|
|
1277
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { id: `${inputId}-error`, className: "text-sm text-destructive", children: error })
|
|
1278
|
+
] });
|
|
1279
|
+
}
|
|
1280
|
+
var ArrayField = React10.forwardRef(ArrayFieldInner);
|
|
1281
|
+
|
|
1282
|
+
// src/components/inputs/image-picker.tsx
|
|
1283
|
+
var React13 = __toESM(require("react"), 1);
|
|
1284
|
+
|
|
1285
|
+
// src/components/media/media-browser.tsx
|
|
1286
|
+
var React11 = __toESM(require("react"), 1);
|
|
1287
|
+
var import_jsx_runtime16 = require("react/jsx-runtime");
|
|
1288
|
+
function MediaBrowser({ onSelect, className, pageSize = 12 }) {
|
|
1289
|
+
const api = useApiClient();
|
|
1290
|
+
const [items, setItems] = React11.useState([]);
|
|
1291
|
+
const [loading, setLoading] = React11.useState(true);
|
|
1292
|
+
const [error, setError] = React11.useState(null);
|
|
1293
|
+
const [hasMore, setHasMore] = React11.useState(false);
|
|
1294
|
+
const [page, setPage] = React11.useState(0);
|
|
1295
|
+
const fileInputRef = React11.useRef(null);
|
|
1296
|
+
const fetchMedia = React11.useCallback(
|
|
1297
|
+
async (pageNum, append) => {
|
|
1298
|
+
setLoading(true);
|
|
1299
|
+
setError(null);
|
|
1300
|
+
const result = await api.get(
|
|
1301
|
+
`/media?limit=${pageSize}&offset=${pageNum * pageSize}`
|
|
1302
|
+
);
|
|
1303
|
+
if (result.error) {
|
|
1304
|
+
setError(result.error.message);
|
|
1305
|
+
setLoading(false);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const newItems = result.data ?? [];
|
|
1309
|
+
setItems((prev) => append ? [...prev, ...newItems] : newItems);
|
|
1310
|
+
setHasMore(newItems.length >= pageSize);
|
|
1311
|
+
setLoading(false);
|
|
1312
|
+
},
|
|
1313
|
+
[api, pageSize]
|
|
1314
|
+
);
|
|
1315
|
+
React11.useEffect(() => {
|
|
1316
|
+
void fetchMedia(0, false);
|
|
1317
|
+
}, [fetchMedia]);
|
|
1318
|
+
const handleLoadMore = () => {
|
|
1319
|
+
const nextPage = page + 1;
|
|
1320
|
+
setPage(nextPage);
|
|
1321
|
+
void fetchMedia(nextPage, true);
|
|
1322
|
+
};
|
|
1323
|
+
const handleUpload = async (e) => {
|
|
1324
|
+
const file = e.target.files?.[0];
|
|
1325
|
+
if (!file) return;
|
|
1326
|
+
const formData = new FormData();
|
|
1327
|
+
formData.append("file", file);
|
|
1328
|
+
setLoading(true);
|
|
1329
|
+
setError(null);
|
|
1330
|
+
const result = await api.upload("/media", formData);
|
|
1331
|
+
if (result.error) {
|
|
1332
|
+
setError(result.error.message);
|
|
1333
|
+
setLoading(false);
|
|
1334
|
+
} else {
|
|
1335
|
+
setPage(0);
|
|
1336
|
+
await fetchMedia(0, false);
|
|
1337
|
+
}
|
|
1338
|
+
if (fileInputRef.current) {
|
|
1339
|
+
fileInputRef.current.value = "";
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
const handleDelete = async (item) => {
|
|
1343
|
+
const result = await api.delete(`/media/${item.id}`);
|
|
1344
|
+
if (result.error) {
|
|
1345
|
+
setError(result.error.message);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
1349
|
+
};
|
|
1350
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: cn("space-y-4", className), "data-testid": "media-browser", children: [
|
|
1351
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex items-center justify-between", children: [
|
|
1352
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h2", { className: "text-xl font-semibold", children: "Media" }),
|
|
1353
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { children: [
|
|
1354
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1355
|
+
"input",
|
|
1356
|
+
{
|
|
1357
|
+
ref: fileInputRef,
|
|
1358
|
+
type: "file",
|
|
1359
|
+
accept: "image/*",
|
|
1360
|
+
className: "hidden",
|
|
1361
|
+
onChange: (e) => void handleUpload(e),
|
|
1362
|
+
"data-testid": "file-input"
|
|
1363
|
+
}
|
|
1364
|
+
),
|
|
1365
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1366
|
+
Button,
|
|
1367
|
+
{
|
|
1368
|
+
type: "button",
|
|
1369
|
+
onClick: () => fileInputRef.current?.click(),
|
|
1370
|
+
"data-testid": "upload-button",
|
|
1371
|
+
children: "Upload"
|
|
1372
|
+
}
|
|
1373
|
+
)
|
|
1374
|
+
] })
|
|
1375
|
+
] }),
|
|
1376
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "text-sm text-destructive", "data-testid": "error", children: error }),
|
|
1377
|
+
!loading && items.length === 0 && !error && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "text-sm text-muted-foreground text-center py-8", "data-testid": "empty-state", children: "No media files yet. Upload your first file." }),
|
|
1378
|
+
items.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1379
|
+
"div",
|
|
1380
|
+
{
|
|
1381
|
+
className: "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4",
|
|
1382
|
+
"data-testid": "media-grid",
|
|
1383
|
+
children: items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
|
|
1384
|
+
"div",
|
|
1385
|
+
{
|
|
1386
|
+
className: "group relative rounded-md border border-input bg-background overflow-hidden",
|
|
1387
|
+
"data-testid": `media-item-${item.id}`,
|
|
1388
|
+
children: [
|
|
1389
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1390
|
+
"button",
|
|
1391
|
+
{
|
|
1392
|
+
type: "button",
|
|
1393
|
+
className: "w-full aspect-square bg-muted flex items-center justify-center overflow-hidden cursor-pointer",
|
|
1394
|
+
onClick: () => onSelect?.(item),
|
|
1395
|
+
"data-testid": `media-select-${item.id}`,
|
|
1396
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("img", { src: item.url, alt: item.filename, className: "h-full w-full object-cover" })
|
|
1397
|
+
}
|
|
1398
|
+
),
|
|
1399
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "p-2 flex items-center justify-between", children: [
|
|
1400
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "text-xs text-muted-foreground truncate flex-1", children: item.filename }),
|
|
1401
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1402
|
+
Button,
|
|
1403
|
+
{
|
|
1404
|
+
type: "button",
|
|
1405
|
+
variant: "ghost",
|
|
1406
|
+
size: "icon",
|
|
1407
|
+
className: "h-6 w-6",
|
|
1408
|
+
onClick: () => void handleDelete(item),
|
|
1409
|
+
title: "Delete",
|
|
1410
|
+
"data-testid": `media-delete-${item.id}`,
|
|
1411
|
+
children: "\u2715"
|
|
1412
|
+
}
|
|
1413
|
+
)
|
|
1414
|
+
] })
|
|
1415
|
+
]
|
|
1416
|
+
},
|
|
1417
|
+
item.id
|
|
1418
|
+
))
|
|
1419
|
+
}
|
|
1420
|
+
),
|
|
1421
|
+
loading && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "text-sm text-muted-foreground", "data-testid": "loading", children: "Loading media..." }),
|
|
1422
|
+
hasMore && !loading && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-center", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Button, { type: "button", variant: "outline", onClick: handleLoadMore, "data-testid": "load-more", children: "Load More" }) })
|
|
1423
|
+
] });
|
|
1424
|
+
}
|
|
1425
|
+
MediaBrowser.displayName = "MediaBrowser";
|
|
1426
|
+
|
|
1427
|
+
// src/components/ui/dialog.tsx
|
|
1428
|
+
var React12 = __toESM(require("react"), 1);
|
|
1429
|
+
var import_react_dom = require("react-dom");
|
|
1430
|
+
var import_jsx_runtime17 = require("react/jsx-runtime");
|
|
1431
|
+
function Dialog({ open, onClose, children, className, title }) {
|
|
1432
|
+
const overlayRef = React12.useRef(null);
|
|
1433
|
+
React12.useEffect(() => {
|
|
1434
|
+
if (!open) return;
|
|
1435
|
+
const handleKeyDown = (e) => {
|
|
1436
|
+
if (e.key === "Escape") {
|
|
1437
|
+
onClose();
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
1441
|
+
document.body.style.overflow = "hidden";
|
|
1442
|
+
return () => {
|
|
1443
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
1444
|
+
document.body.style.overflow = "";
|
|
1445
|
+
};
|
|
1446
|
+
}, [open, onClose]);
|
|
1447
|
+
const handleOverlayClick = (e) => {
|
|
1448
|
+
if (e.target === overlayRef.current) {
|
|
1449
|
+
onClose();
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
if (!open) return null;
|
|
1453
|
+
return (0, import_react_dom.createPortal)(
|
|
1454
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1455
|
+
"div",
|
|
1456
|
+
{
|
|
1457
|
+
ref: overlayRef,
|
|
1458
|
+
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50",
|
|
1459
|
+
onClick: handleOverlayClick,
|
|
1460
|
+
onKeyDown: (e) => {
|
|
1461
|
+
if (e.key === "Enter" || e.key === "Escape") {
|
|
1462
|
+
e.preventDefault();
|
|
1463
|
+
onClose();
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
role: "button",
|
|
1467
|
+
tabIndex: -1,
|
|
1468
|
+
"data-testid": "dialog-overlay",
|
|
1469
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
|
|
1470
|
+
"div",
|
|
1471
|
+
{
|
|
1472
|
+
className: cn(
|
|
1473
|
+
"relative mx-4 max-h-[85vh] w-full max-w-3xl overflow-auto rounded-lg border border-input bg-background p-6 shadow-lg",
|
|
1474
|
+
className
|
|
1475
|
+
),
|
|
1476
|
+
role: "dialog",
|
|
1477
|
+
"aria-modal": "true",
|
|
1478
|
+
"aria-label": title,
|
|
1479
|
+
"data-testid": "dialog-content",
|
|
1480
|
+
children: [
|
|
1481
|
+
title && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "mb-4 flex items-center justify-between", children: [
|
|
1482
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("h2", { className: "text-lg font-semibold", children: title }),
|
|
1483
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1484
|
+
"button",
|
|
1485
|
+
{
|
|
1486
|
+
type: "button",
|
|
1487
|
+
onClick: onClose,
|
|
1488
|
+
className: "rounded-sm p-1 text-muted-foreground hover:text-foreground",
|
|
1489
|
+
"aria-label": "Close",
|
|
1490
|
+
"data-testid": "dialog-close",
|
|
1491
|
+
children: "\u2715"
|
|
1492
|
+
}
|
|
1493
|
+
)
|
|
1494
|
+
] }),
|
|
1495
|
+
children
|
|
1496
|
+
]
|
|
1497
|
+
}
|
|
1498
|
+
)
|
|
1499
|
+
}
|
|
1500
|
+
),
|
|
1501
|
+
document.body
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
Dialog.displayName = "Dialog";
|
|
1505
|
+
|
|
1506
|
+
// src/components/inputs/image-picker.tsx
|
|
1507
|
+
var import_jsx_runtime18 = require("react/jsx-runtime");
|
|
1508
|
+
function ImagePicker({
|
|
1509
|
+
label,
|
|
1510
|
+
value,
|
|
1511
|
+
onChange,
|
|
1512
|
+
onBrowse,
|
|
1513
|
+
error,
|
|
1514
|
+
required,
|
|
1515
|
+
className,
|
|
1516
|
+
id,
|
|
1517
|
+
name
|
|
1518
|
+
}) {
|
|
1519
|
+
const inputId = id || name || React13.useId();
|
|
1520
|
+
const [mediaBrowserOpen, setMediaBrowserOpen] = React13.useState(false);
|
|
1521
|
+
const handleClear = () => {
|
|
1522
|
+
onChange?.("");
|
|
1523
|
+
};
|
|
1524
|
+
const handleBrowse = onBrowse ?? (() => setMediaBrowserOpen(true));
|
|
1525
|
+
const handleMediaSelect = (item) => {
|
|
1526
|
+
onChange?.(item.url);
|
|
1527
|
+
setMediaBrowserOpen(false);
|
|
1528
|
+
};
|
|
1529
|
+
return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: cn("space-y-2", className), children: [
|
|
1530
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(Label, { htmlFor: inputId, children: [
|
|
1531
|
+
label,
|
|
1532
|
+
required && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "text-destructive ml-1", children: "*" })
|
|
1533
|
+
] }),
|
|
1534
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1535
|
+
"div",
|
|
1536
|
+
{
|
|
1537
|
+
className: cn(
|
|
1538
|
+
"rounded-md border border-input bg-background p-4",
|
|
1539
|
+
error && "border-destructive"
|
|
1540
|
+
),
|
|
1541
|
+
children: value ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "space-y-3", children: [
|
|
1542
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "relative aspect-video w-full max-w-xs overflow-hidden rounded-md bg-muted", children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1543
|
+
"img",
|
|
1544
|
+
{
|
|
1545
|
+
src: value,
|
|
1546
|
+
alt: "Preview",
|
|
1547
|
+
className: "h-full w-full object-cover",
|
|
1548
|
+
"data-testid": "image-preview"
|
|
1549
|
+
}
|
|
1550
|
+
) }),
|
|
1551
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex gap-2", children: [
|
|
1552
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Button, { type: "button", variant: "outline", size: "sm", onClick: handleBrowse, children: "Change" }),
|
|
1553
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1554
|
+
Button,
|
|
1555
|
+
{
|
|
1556
|
+
type: "button",
|
|
1557
|
+
variant: "outline",
|
|
1558
|
+
size: "sm",
|
|
1559
|
+
onClick: handleClear,
|
|
1560
|
+
"data-testid": "clear-button",
|
|
1561
|
+
children: "Clear"
|
|
1562
|
+
}
|
|
1563
|
+
)
|
|
1564
|
+
] })
|
|
1565
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex flex-col items-center justify-center py-8 text-center", children: [
|
|
1566
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "mb-4 text-4xl text-muted-foreground", children: "\u{1F5BC}\uFE0F" }),
|
|
1567
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "mb-4 text-sm text-muted-foreground", children: "No image selected" }),
|
|
1568
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1569
|
+
Button,
|
|
1570
|
+
{
|
|
1571
|
+
type: "button",
|
|
1572
|
+
variant: "outline",
|
|
1573
|
+
onClick: handleBrowse,
|
|
1574
|
+
id: inputId,
|
|
1575
|
+
"data-testid": "browse-button",
|
|
1576
|
+
children: "Browse Media"
|
|
1577
|
+
}
|
|
1578
|
+
)
|
|
1579
|
+
] })
|
|
1580
|
+
}
|
|
1581
|
+
),
|
|
1582
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { id: `${inputId}-error`, className: "text-sm text-destructive", children: error }),
|
|
1583
|
+
!onBrowse && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1584
|
+
Dialog,
|
|
1585
|
+
{
|
|
1586
|
+
open: mediaBrowserOpen,
|
|
1587
|
+
onClose: () => setMediaBrowserOpen(false),
|
|
1588
|
+
title: "Select Media",
|
|
1589
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(MediaBrowser, { onSelect: handleMediaSelect })
|
|
1590
|
+
}
|
|
1591
|
+
)
|
|
1592
|
+
] });
|
|
1593
|
+
}
|
|
1594
|
+
ImagePicker.displayName = "ImagePicker";
|
|
1595
|
+
|
|
1596
|
+
// src/components/inputs/object-field.tsx
|
|
1597
|
+
var React14 = __toESM(require("react"), 1);
|
|
1598
|
+
var import_jsx_runtime19 = require("react/jsx-runtime");
|
|
1599
|
+
function ObjectField({ label, children, error, required, className, id, name }) {
|
|
1600
|
+
const fieldId = id || name || React14.useId();
|
|
1601
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: cn("space-y-2", className), children: [
|
|
1602
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(Label, { htmlFor: fieldId, children: [
|
|
1603
|
+
label,
|
|
1604
|
+
required && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "text-destructive ml-1", children: "*" })
|
|
1605
|
+
] }),
|
|
1606
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
|
|
1607
|
+
"div",
|
|
1608
|
+
{
|
|
1609
|
+
id: fieldId,
|
|
1610
|
+
className: cn(
|
|
1611
|
+
"rounded-md border border-input bg-muted/30 p-4 space-y-4",
|
|
1612
|
+
error && "border-destructive"
|
|
1613
|
+
),
|
|
1614
|
+
role: "group",
|
|
1615
|
+
"aria-labelledby": `${fieldId}-label`,
|
|
1616
|
+
"aria-describedby": error ? `${fieldId}-error` : void 0,
|
|
1617
|
+
"data-testid": "object-field-container",
|
|
1618
|
+
children
|
|
1619
|
+
}
|
|
1620
|
+
),
|
|
1621
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { id: `${fieldId}-error`, className: "text-sm text-destructive", children: error })
|
|
1622
|
+
] });
|
|
1623
|
+
}
|
|
1624
|
+
ObjectField.displayName = "ObjectField";
|
|
1625
|
+
|
|
1626
|
+
// src/components/inputs/rich-text-editor.tsx
|
|
1627
|
+
var import_extension_link = __toESM(require("@tiptap/extension-link"), 1);
|
|
1628
|
+
var import_react5 = require("@tiptap/react");
|
|
1629
|
+
var import_starter_kit = __toESM(require("@tiptap/starter-kit"), 1);
|
|
1630
|
+
var React15 = __toESM(require("react"), 1);
|
|
1631
|
+
var import_jsx_runtime20 = require("react/jsx-runtime");
|
|
1632
|
+
function ToolbarButton({ onClick, isActive, disabled, children, title }) {
|
|
1633
|
+
return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1634
|
+
"button",
|
|
1635
|
+
{
|
|
1636
|
+
type: "button",
|
|
1637
|
+
onClick,
|
|
1638
|
+
disabled,
|
|
1639
|
+
title,
|
|
1640
|
+
className: cn(
|
|
1641
|
+
"px-2 py-1 text-sm rounded hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed",
|
|
1642
|
+
isActive && "bg-accent text-accent-foreground"
|
|
1643
|
+
),
|
|
1644
|
+
children
|
|
1645
|
+
}
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
function RichTextEditor({
|
|
1649
|
+
label,
|
|
1650
|
+
value = "",
|
|
1651
|
+
onChange,
|
|
1652
|
+
error,
|
|
1653
|
+
required,
|
|
1654
|
+
placeholder,
|
|
1655
|
+
className,
|
|
1656
|
+
id,
|
|
1657
|
+
name
|
|
1658
|
+
}) {
|
|
1659
|
+
const inputId = id || name || React15.useId();
|
|
1660
|
+
const editor = (0, import_react5.useEditor)({
|
|
1661
|
+
extensions: [
|
|
1662
|
+
import_starter_kit.default.configure({
|
|
1663
|
+
heading: {
|
|
1664
|
+
levels: [1, 2, 3]
|
|
1665
|
+
}
|
|
1666
|
+
}),
|
|
1667
|
+
import_extension_link.default.extend({ name: "customLink" }).configure({
|
|
1668
|
+
openOnClick: false,
|
|
1669
|
+
HTMLAttributes: {
|
|
1670
|
+
class: "text-primary underline"
|
|
1671
|
+
}
|
|
1672
|
+
})
|
|
1673
|
+
],
|
|
1674
|
+
content: value,
|
|
1675
|
+
immediatelyRender: false,
|
|
1676
|
+
editorProps: {
|
|
1677
|
+
attributes: {
|
|
1678
|
+
class: "prose prose-sm max-w-none min-h-[150px] p-3 focus:outline-none",
|
|
1679
|
+
"aria-invalid": error ? "true" : "false",
|
|
1680
|
+
...error ? { "aria-describedby": `${inputId}-error` } : {}
|
|
1681
|
+
}
|
|
1682
|
+
},
|
|
1683
|
+
onUpdate: ({ editor: updatedEditor }) => {
|
|
1684
|
+
onChange?.(updatedEditor.getHTML());
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
const setLink = React15.useCallback(() => {
|
|
1688
|
+
if (!editor) return;
|
|
1689
|
+
const previousUrl = editor.getAttributes("link").href;
|
|
1690
|
+
const url = window.prompt("URL", previousUrl);
|
|
1691
|
+
if (url === null) {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (url === "") {
|
|
1695
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
|
1699
|
+
}, [editor]);
|
|
1700
|
+
if (!editor) {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
return /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("div", { className: cn("space-y-2", className), children: [
|
|
1704
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsxs)(Label, { htmlFor: inputId, children: [
|
|
1705
|
+
label,
|
|
1706
|
+
required && /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("span", { className: "text-destructive ml-1", children: "*" })
|
|
1707
|
+
] }),
|
|
1708
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsxs)(
|
|
1709
|
+
"div",
|
|
1710
|
+
{
|
|
1711
|
+
className: cn(
|
|
1712
|
+
"rounded-md border border-input bg-background",
|
|
1713
|
+
error && "border-destructive"
|
|
1714
|
+
),
|
|
1715
|
+
children: [
|
|
1716
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("div", { className: "flex flex-wrap gap-1 border-b border-input p-2", children: [
|
|
1717
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1718
|
+
ToolbarButton,
|
|
1719
|
+
{
|
|
1720
|
+
onClick: () => editor.chain().focus().toggleBold().run(),
|
|
1721
|
+
isActive: editor.isActive("bold"),
|
|
1722
|
+
disabled: !editor.can().chain().focus().toggleBold().run(),
|
|
1723
|
+
title: "Bold",
|
|
1724
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("strong", { children: "B" })
|
|
1725
|
+
}
|
|
1726
|
+
),
|
|
1727
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1728
|
+
ToolbarButton,
|
|
1729
|
+
{
|
|
1730
|
+
onClick: () => editor.chain().focus().toggleItalic().run(),
|
|
1731
|
+
isActive: editor.isActive("italic"),
|
|
1732
|
+
disabled: !editor.can().chain().focus().toggleItalic().run(),
|
|
1733
|
+
title: "Italic",
|
|
1734
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("em", { children: "I" })
|
|
1735
|
+
}
|
|
1736
|
+
),
|
|
1737
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(ToolbarButton, { onClick: setLink, isActive: editor.isActive("link"), title: "Link", children: "\u{1F517}" }),
|
|
1738
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)("div", { className: "w-px bg-border mx-1" }),
|
|
1739
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1740
|
+
ToolbarButton,
|
|
1741
|
+
{
|
|
1742
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
1743
|
+
isActive: editor.isActive("heading", { level: 1 }),
|
|
1744
|
+
title: "Heading 1",
|
|
1745
|
+
children: "H1"
|
|
1746
|
+
}
|
|
1747
|
+
),
|
|
1748
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1749
|
+
ToolbarButton,
|
|
1750
|
+
{
|
|
1751
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
1752
|
+
isActive: editor.isActive("heading", { level: 2 }),
|
|
1753
|
+
title: "Heading 2",
|
|
1754
|
+
children: "H2"
|
|
1755
|
+
}
|
|
1756
|
+
),
|
|
1757
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1758
|
+
ToolbarButton,
|
|
1759
|
+
{
|
|
1760
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
|
1761
|
+
isActive: editor.isActive("heading", { level: 3 }),
|
|
1762
|
+
title: "Heading 3",
|
|
1763
|
+
children: "H3"
|
|
1764
|
+
}
|
|
1765
|
+
),
|
|
1766
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)("div", { className: "w-px bg-border mx-1" }),
|
|
1767
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1768
|
+
ToolbarButton,
|
|
1769
|
+
{
|
|
1770
|
+
onClick: () => editor.chain().focus().toggleBulletList().run(),
|
|
1771
|
+
isActive: editor.isActive("bulletList"),
|
|
1772
|
+
title: "Bullet List",
|
|
1773
|
+
children: "\u2022"
|
|
1774
|
+
}
|
|
1775
|
+
),
|
|
1776
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
1777
|
+
ToolbarButton,
|
|
1778
|
+
{
|
|
1779
|
+
onClick: () => editor.chain().focus().toggleOrderedList().run(),
|
|
1780
|
+
isActive: editor.isActive("orderedList"),
|
|
1781
|
+
title: "Ordered List",
|
|
1782
|
+
children: "1."
|
|
1783
|
+
}
|
|
1784
|
+
)
|
|
1785
|
+
] }),
|
|
1786
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(import_react5.EditorContent, { editor, id: inputId, "data-placeholder": placeholder })
|
|
1787
|
+
]
|
|
1788
|
+
}
|
|
1789
|
+
),
|
|
1790
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("p", { id: `${inputId}-error`, className: "text-sm text-destructive", children: error })
|
|
1791
|
+
] });
|
|
1792
|
+
}
|
|
1793
|
+
RichTextEditor.displayName = "RichTextEditor";
|
|
1794
|
+
|
|
1795
|
+
// src/components/inputs/string-input.tsx
|
|
1796
|
+
var React16 = __toESM(require("react"), 1);
|
|
1797
|
+
var import_jsx_runtime21 = require("react/jsx-runtime");
|
|
1798
|
+
var StringInput = React16.forwardRef(
|
|
1799
|
+
({ className, label, error, required, id, ...props }, ref) => {
|
|
1800
|
+
const inputId = id || props.name || React16.useId();
|
|
1801
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: cn("space-y-2", className), children: [
|
|
1802
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(Label, { htmlFor: inputId, children: [
|
|
1803
|
+
label,
|
|
1804
|
+
required && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-destructive ml-1", children: "*" })
|
|
1805
|
+
] }),
|
|
1806
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
|
|
1807
|
+
Input,
|
|
1808
|
+
{
|
|
1809
|
+
id: inputId,
|
|
1810
|
+
ref,
|
|
1811
|
+
type: "text",
|
|
1812
|
+
"aria-invalid": !!error,
|
|
1813
|
+
"aria-describedby": error ? `${inputId}-error` : void 0,
|
|
1814
|
+
className: cn(error && "border-destructive"),
|
|
1815
|
+
...props
|
|
1816
|
+
}
|
|
1817
|
+
),
|
|
1818
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { id: `${inputId}-error`, className: "text-sm text-destructive", children: error })
|
|
1819
|
+
] });
|
|
1820
|
+
}
|
|
1821
|
+
);
|
|
1822
|
+
StringInput.displayName = "StringInput";
|
|
1823
|
+
|
|
1824
|
+
// src/components/inputs/text-input.tsx
|
|
1825
|
+
var React18 = __toESM(require("react"), 1);
|
|
1826
|
+
|
|
1827
|
+
// src/components/ui/textarea.tsx
|
|
1828
|
+
var React17 = __toESM(require("react"), 1);
|
|
1829
|
+
var import_jsx_runtime22 = require("react/jsx-runtime");
|
|
1830
|
+
var Textarea = React17.forwardRef(
|
|
1831
|
+
({ className, ...props }, ref) => {
|
|
1832
|
+
return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
|
|
1833
|
+
"textarea",
|
|
1834
|
+
{
|
|
1835
|
+
className: cn(
|
|
1836
|
+
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
1837
|
+
className
|
|
1838
|
+
),
|
|
1839
|
+
ref,
|
|
1840
|
+
...props
|
|
1841
|
+
}
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
);
|
|
1845
|
+
Textarea.displayName = "Textarea";
|
|
1846
|
+
|
|
1847
|
+
// src/components/inputs/text-input.tsx
|
|
1848
|
+
var import_jsx_runtime23 = require("react/jsx-runtime");
|
|
1849
|
+
var TextInput = React18.forwardRef(
|
|
1850
|
+
({ className, label, error, required, id, rows = 3, ...props }, ref) => {
|
|
1851
|
+
const inputId = id || props.name || React18.useId();
|
|
1852
|
+
return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: cn("space-y-2", className), children: [
|
|
1853
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(Label, { htmlFor: inputId, children: [
|
|
1854
|
+
label,
|
|
1855
|
+
required && /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("span", { className: "text-destructive ml-1", children: "*" })
|
|
1856
|
+
] }),
|
|
1857
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
|
|
1858
|
+
Textarea,
|
|
1859
|
+
{
|
|
1860
|
+
id: inputId,
|
|
1861
|
+
ref,
|
|
1862
|
+
rows,
|
|
1863
|
+
"aria-invalid": !!error,
|
|
1864
|
+
"aria-describedby": error ? `${inputId}-error` : void 0,
|
|
1865
|
+
className: cn(error && "border-destructive"),
|
|
1866
|
+
...props
|
|
1867
|
+
}
|
|
1868
|
+
),
|
|
1869
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("p", { id: `${inputId}-error`, className: "text-sm text-destructive", children: error })
|
|
1870
|
+
] });
|
|
1871
|
+
}
|
|
1872
|
+
);
|
|
1873
|
+
TextInput.displayName = "TextInput";
|
|
1874
|
+
|
|
1875
|
+
// src/lib/form-generator.tsx
|
|
1876
|
+
var import_jsx_runtime24 = require("react/jsx-runtime");
|
|
1877
|
+
function unwrapSchema(schema) {
|
|
1878
|
+
if ("unwrap" in schema && typeof schema.unwrap === "function") {
|
|
1879
|
+
return unwrapSchema(schema.unwrap());
|
|
1880
|
+
}
|
|
1881
|
+
if ("_def" in schema) {
|
|
1882
|
+
const def = schema._def;
|
|
1883
|
+
if ("innerType" in def && def.innerType) {
|
|
1884
|
+
return unwrapSchema(def.innerType);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return schema;
|
|
1888
|
+
}
|
|
1889
|
+
function resolveFieldType(schema) {
|
|
1890
|
+
const meta = (0, import_core.getFieldMeta)(schema);
|
|
1891
|
+
if (meta) return meta.fieldType;
|
|
1892
|
+
const unwrapped = unwrapSchema(schema);
|
|
1893
|
+
const unwrappedMeta = (0, import_core.getFieldMeta)(unwrapped);
|
|
1894
|
+
return unwrappedMeta?.fieldType ?? null;
|
|
1895
|
+
}
|
|
1896
|
+
function fieldNameToLabel(name) {
|
|
1897
|
+
return name.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase()).trim();
|
|
1898
|
+
}
|
|
1899
|
+
function FormGenerator({
|
|
1900
|
+
schema,
|
|
1901
|
+
onSubmit,
|
|
1902
|
+
onChange,
|
|
1903
|
+
defaultValues,
|
|
1904
|
+
submitLabel = "Submit",
|
|
1905
|
+
className
|
|
1906
|
+
}) {
|
|
1907
|
+
const {
|
|
1908
|
+
register,
|
|
1909
|
+
handleSubmit,
|
|
1910
|
+
control,
|
|
1911
|
+
watch,
|
|
1912
|
+
formState: { errors }
|
|
1913
|
+
} = (0, import_react_hook_form.useForm)({
|
|
1914
|
+
resolver: (0, import_zod.zodResolver)(schema),
|
|
1915
|
+
defaultValues
|
|
1916
|
+
});
|
|
1917
|
+
React19.useEffect(() => {
|
|
1918
|
+
if (!onChange) return;
|
|
1919
|
+
const subscription = watch((values) => {
|
|
1920
|
+
onChange(values);
|
|
1921
|
+
});
|
|
1922
|
+
return () => subscription.unsubscribe();
|
|
1923
|
+
}, [watch, onChange]);
|
|
1924
|
+
const shape = schema.shape;
|
|
1925
|
+
const renderField = (fieldName, fieldSchema) => {
|
|
1926
|
+
const fieldType = resolveFieldType(fieldSchema);
|
|
1927
|
+
const label = fieldNameToLabel(fieldName);
|
|
1928
|
+
const isRequired = !fieldSchema.isOptional();
|
|
1929
|
+
const fieldError = errors[fieldName];
|
|
1930
|
+
const errorMessage = fieldError?.message;
|
|
1931
|
+
switch (fieldType) {
|
|
1932
|
+
case "string":
|
|
1933
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1934
|
+
StringInput,
|
|
1935
|
+
{
|
|
1936
|
+
label,
|
|
1937
|
+
required: isRequired,
|
|
1938
|
+
error: errorMessage,
|
|
1939
|
+
...register(fieldName)
|
|
1940
|
+
},
|
|
1941
|
+
fieldName
|
|
1942
|
+
);
|
|
1943
|
+
case "text":
|
|
1944
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1945
|
+
TextInput,
|
|
1946
|
+
{
|
|
1947
|
+
label,
|
|
1948
|
+
required: isRequired,
|
|
1949
|
+
error: errorMessage,
|
|
1950
|
+
...register(fieldName)
|
|
1951
|
+
},
|
|
1952
|
+
fieldName
|
|
1953
|
+
);
|
|
1954
|
+
case "richtext":
|
|
1955
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1956
|
+
import_react_hook_form.Controller,
|
|
1957
|
+
{
|
|
1958
|
+
name: fieldName,
|
|
1959
|
+
control,
|
|
1960
|
+
render: ({ field }) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1961
|
+
RichTextEditor,
|
|
1962
|
+
{
|
|
1963
|
+
label,
|
|
1964
|
+
required: isRequired,
|
|
1965
|
+
error: errorMessage,
|
|
1966
|
+
value: field.value,
|
|
1967
|
+
onChange: field.onChange,
|
|
1968
|
+
name: field.name
|
|
1969
|
+
}
|
|
1970
|
+
)
|
|
1971
|
+
},
|
|
1972
|
+
fieldName
|
|
1973
|
+
);
|
|
1974
|
+
case "image":
|
|
1975
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1976
|
+
import_react_hook_form.Controller,
|
|
1977
|
+
{
|
|
1978
|
+
name: fieldName,
|
|
1979
|
+
control,
|
|
1980
|
+
render: ({ field }) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1981
|
+
ImagePicker,
|
|
1982
|
+
{
|
|
1983
|
+
label,
|
|
1984
|
+
required: isRequired,
|
|
1985
|
+
error: errorMessage,
|
|
1986
|
+
value: field.value,
|
|
1987
|
+
onChange: field.onChange,
|
|
1988
|
+
name: field.name
|
|
1989
|
+
}
|
|
1990
|
+
)
|
|
1991
|
+
},
|
|
1992
|
+
fieldName
|
|
1993
|
+
);
|
|
1994
|
+
case "array":
|
|
1995
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
1996
|
+
import_react_hook_form.Controller,
|
|
1997
|
+
{
|
|
1998
|
+
name: fieldName,
|
|
1999
|
+
control,
|
|
2000
|
+
render: ({ field }) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
2001
|
+
ArrayField,
|
|
2002
|
+
{
|
|
2003
|
+
label,
|
|
2004
|
+
required: isRequired,
|
|
2005
|
+
error: errorMessage,
|
|
2006
|
+
value: field.value ?? [],
|
|
2007
|
+
onChange: field.onChange,
|
|
2008
|
+
name: field.name,
|
|
2009
|
+
createDefaultItem: () => "",
|
|
2010
|
+
renderItem: (item, index, onItemChange) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
2011
|
+
Input,
|
|
2012
|
+
{
|
|
2013
|
+
value: item,
|
|
2014
|
+
onChange: (e) => onItemChange(e.target.value),
|
|
2015
|
+
"data-testid": `${fieldName}-item-${index}`
|
|
2016
|
+
}
|
|
2017
|
+
)
|
|
2018
|
+
}
|
|
2019
|
+
)
|
|
2020
|
+
},
|
|
2021
|
+
fieldName
|
|
2022
|
+
);
|
|
2023
|
+
case "object": {
|
|
2024
|
+
const innerSchema = unwrapSchema(fieldSchema);
|
|
2025
|
+
const innerShape = "shape" in innerSchema ? innerSchema.shape : null;
|
|
2026
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(ObjectField, { label, required: isRequired, error: errorMessage, children: innerShape ? Object.entries(innerShape).map(([subName, _subSchema]) => {
|
|
2027
|
+
const subFieldName = `${fieldName}.${subName}`;
|
|
2028
|
+
const subLabel = fieldNameToLabel(subName);
|
|
2029
|
+
const subError = errors[fieldName];
|
|
2030
|
+
const subErrorMessage = subError?.[subName]?.message;
|
|
2031
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
2032
|
+
StringInput,
|
|
2033
|
+
{
|
|
2034
|
+
label: subLabel,
|
|
2035
|
+
error: subErrorMessage,
|
|
2036
|
+
...register(subFieldName)
|
|
2037
|
+
},
|
|
2038
|
+
subFieldName
|
|
2039
|
+
);
|
|
2040
|
+
}) : null }, fieldName);
|
|
2041
|
+
}
|
|
2042
|
+
default:
|
|
2043
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
2044
|
+
StringInput,
|
|
2045
|
+
{
|
|
2046
|
+
label,
|
|
2047
|
+
required: isRequired,
|
|
2048
|
+
error: errorMessage,
|
|
2049
|
+
...register(fieldName)
|
|
2050
|
+
},
|
|
2051
|
+
fieldName
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(
|
|
2056
|
+
"form",
|
|
2057
|
+
{
|
|
2058
|
+
onSubmit: handleSubmit(onSubmit),
|
|
2059
|
+
className: cn("space-y-4", className),
|
|
2060
|
+
"data-testid": "form-generator",
|
|
2061
|
+
children: [
|
|
2062
|
+
Object.entries(shape).map(([fieldName, fieldSchema]) => renderField(fieldName, fieldSchema)),
|
|
2063
|
+
!onChange && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(Button, { type: "submit", "data-testid": "form-submit", children: submitLabel })
|
|
2064
|
+
]
|
|
2065
|
+
}
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
FormGenerator.displayName = "FormGenerator";
|
|
2069
|
+
|
|
2070
|
+
// src/components/editors/section-editor.tsx
|
|
2071
|
+
var import_jsx_runtime25 = require("react/jsx-runtime");
|
|
2072
|
+
function SectionEditor({
|
|
2073
|
+
sectionType,
|
|
2074
|
+
data,
|
|
2075
|
+
onChange,
|
|
2076
|
+
submitLabel = "Save Section",
|
|
2077
|
+
className
|
|
2078
|
+
}) {
|
|
2079
|
+
const { registry } = useAdmin();
|
|
2080
|
+
const section = registry.getSection(sectionType);
|
|
2081
|
+
if (!section) {
|
|
2082
|
+
return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
|
|
2083
|
+
"div",
|
|
2084
|
+
{
|
|
2085
|
+
className: cn("rounded-md border border-destructive p-4", className),
|
|
2086
|
+
"data-testid": "section-editor-error",
|
|
2087
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("p", { className: "text-sm text-destructive", children: [
|
|
2088
|
+
"Unknown section type: ",
|
|
2089
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)("strong", { children: sectionType })
|
|
2090
|
+
] })
|
|
2091
|
+
}
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
return /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("div", { className: cn("space-y-4", className), "data-testid": "section-editor", children: [
|
|
2095
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)("h3", { className: "text-lg font-semibold capitalize", children: section.name }),
|
|
2096
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
|
|
2097
|
+
FormGenerator,
|
|
2098
|
+
{
|
|
2099
|
+
schema: section.schema,
|
|
2100
|
+
onSubmit: onChange,
|
|
2101
|
+
onChange,
|
|
2102
|
+
defaultValues: data,
|
|
2103
|
+
submitLabel
|
|
2104
|
+
}
|
|
2105
|
+
)
|
|
2106
|
+
] });
|
|
2107
|
+
}
|
|
2108
|
+
SectionEditor.displayName = "SectionEditor";
|
|
2109
|
+
|
|
2110
|
+
// src/components/editors/page-editor.tsx
|
|
2111
|
+
var import_jsx_runtime26 = require("react/jsx-runtime");
|
|
2112
|
+
function PageEditor({
|
|
2113
|
+
sections: initialSections,
|
|
2114
|
+
allowedSections,
|
|
2115
|
+
onSave,
|
|
2116
|
+
className
|
|
2117
|
+
}) {
|
|
2118
|
+
const { registry } = useAdmin();
|
|
2119
|
+
const [sections, setSections] = React20.useState(initialSections);
|
|
2120
|
+
const [selectedSectionType, setSelectedSectionType] = React20.useState(
|
|
2121
|
+
allowedSections[0] ?? ""
|
|
2122
|
+
);
|
|
2123
|
+
const handleAddSection = () => {
|
|
2124
|
+
if (!selectedSectionType) return;
|
|
2125
|
+
const newSection = {
|
|
2126
|
+
type: selectedSectionType,
|
|
2127
|
+
data: {}
|
|
2128
|
+
};
|
|
2129
|
+
setSections([...sections, newSection]);
|
|
2130
|
+
};
|
|
2131
|
+
const handleRemoveSection = (index) => {
|
|
2132
|
+
const newSections = [...sections];
|
|
2133
|
+
newSections.splice(index, 1);
|
|
2134
|
+
setSections(newSections);
|
|
2135
|
+
};
|
|
2136
|
+
const handleMoveUp = (index) => {
|
|
2137
|
+
if (index === 0) return;
|
|
2138
|
+
const newSections = [...sections];
|
|
2139
|
+
const temp = newSections[index];
|
|
2140
|
+
newSections[index] = newSections[index - 1];
|
|
2141
|
+
newSections[index - 1] = temp;
|
|
2142
|
+
setSections(newSections);
|
|
2143
|
+
};
|
|
2144
|
+
const handleMoveDown = (index) => {
|
|
2145
|
+
if (index === sections.length - 1) return;
|
|
2146
|
+
const newSections = [...sections];
|
|
2147
|
+
const temp = newSections[index];
|
|
2148
|
+
newSections[index] = newSections[index + 1];
|
|
2149
|
+
newSections[index + 1] = temp;
|
|
2150
|
+
setSections(newSections);
|
|
2151
|
+
};
|
|
2152
|
+
const handleSectionChange = (index, data) => {
|
|
2153
|
+
const newSections = [...sections];
|
|
2154
|
+
const current = newSections[index];
|
|
2155
|
+
if (current) {
|
|
2156
|
+
newSections[index] = { ...current, data };
|
|
2157
|
+
}
|
|
2158
|
+
setSections(newSections);
|
|
2159
|
+
};
|
|
2160
|
+
const handleSave = () => {
|
|
2161
|
+
onSave(sections);
|
|
2162
|
+
};
|
|
2163
|
+
return /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("div", { className: cn("space-y-6", className), "data-testid": "page-editor", children: [
|
|
2164
|
+
sections.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("p", { className: "text-sm text-muted-foreground text-center py-8", children: "No sections yet. Add a section to get started." }) : /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("div", { className: "space-y-4", children: sections.map((section, index) => {
|
|
2165
|
+
const sectionDef = registry.getSection(section.type);
|
|
2166
|
+
const sectionLabel = sectionDef?.name ?? section.type;
|
|
2167
|
+
return /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)(
|
|
2168
|
+
"div",
|
|
2169
|
+
{
|
|
2170
|
+
className: "rounded-md border border-input bg-background p-4",
|
|
2171
|
+
"data-testid": `page-section-${index}`,
|
|
2172
|
+
children: [
|
|
2173
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("div", { className: "flex items-center justify-between mb-4", children: [
|
|
2174
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)("h3", { className: "text-sm font-semibold capitalize", children: sectionLabel }),
|
|
2175
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("div", { className: "flex gap-1", children: [
|
|
2176
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
|
|
2177
|
+
Button,
|
|
2178
|
+
{
|
|
2179
|
+
type: "button",
|
|
2180
|
+
variant: "ghost",
|
|
2181
|
+
size: "icon",
|
|
2182
|
+
onClick: () => handleMoveUp(index),
|
|
2183
|
+
disabled: index === 0,
|
|
2184
|
+
title: "Move up",
|
|
2185
|
+
"data-testid": `section-move-up-${index}`,
|
|
2186
|
+
children: "\u2191"
|
|
2187
|
+
}
|
|
2188
|
+
),
|
|
2189
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
|
|
2190
|
+
Button,
|
|
2191
|
+
{
|
|
2192
|
+
type: "button",
|
|
2193
|
+
variant: "ghost",
|
|
2194
|
+
size: "icon",
|
|
2195
|
+
onClick: () => handleMoveDown(index),
|
|
2196
|
+
disabled: index === sections.length - 1,
|
|
2197
|
+
title: "Move down",
|
|
2198
|
+
"data-testid": `section-move-down-${index}`,
|
|
2199
|
+
children: "\u2193"
|
|
2200
|
+
}
|
|
2201
|
+
),
|
|
2202
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
|
|
2203
|
+
Button,
|
|
2204
|
+
{
|
|
2205
|
+
type: "button",
|
|
2206
|
+
variant: "ghost",
|
|
2207
|
+
size: "icon",
|
|
2208
|
+
onClick: () => handleRemoveSection(index),
|
|
2209
|
+
title: "Remove section",
|
|
2210
|
+
"data-testid": `section-remove-${index}`,
|
|
2211
|
+
children: "\u2715"
|
|
2212
|
+
}
|
|
2213
|
+
)
|
|
2214
|
+
] })
|
|
2215
|
+
] }),
|
|
2216
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
|
|
2217
|
+
SectionEditor,
|
|
2218
|
+
{
|
|
2219
|
+
sectionType: section.type,
|
|
2220
|
+
data: section.data,
|
|
2221
|
+
onChange: (data) => handleSectionChange(index, data),
|
|
2222
|
+
submitLabel: "Update Section"
|
|
2223
|
+
}
|
|
2224
|
+
)
|
|
2225
|
+
]
|
|
2226
|
+
},
|
|
2227
|
+
`${section.type}-${index}`
|
|
2228
|
+
);
|
|
2229
|
+
}) }),
|
|
2230
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("div", { className: "flex items-center gap-2 border-t border-input pt-4", children: [
|
|
2231
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
|
|
2232
|
+
"select",
|
|
2233
|
+
{
|
|
2234
|
+
value: selectedSectionType,
|
|
2235
|
+
onChange: (e) => setSelectedSectionType(e.target.value),
|
|
2236
|
+
className: "flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm",
|
|
2237
|
+
"data-testid": "section-type-select",
|
|
2238
|
+
children: allowedSections.map((type) => {
|
|
2239
|
+
const sectionDef = registry.getSection(type);
|
|
2240
|
+
return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("option", { value: type, children: sectionDef?.name ?? type }, type);
|
|
2241
|
+
})
|
|
2242
|
+
}
|
|
2243
|
+
),
|
|
2244
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
|
|
2245
|
+
Button,
|
|
2246
|
+
{
|
|
2247
|
+
type: "button",
|
|
2248
|
+
variant: "outline",
|
|
2249
|
+
onClick: handleAddSection,
|
|
2250
|
+
"data-testid": "add-section",
|
|
2251
|
+
children: "Add Section"
|
|
2252
|
+
}
|
|
2253
|
+
)
|
|
2254
|
+
] }),
|
|
2255
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)("div", { className: "border-t border-input pt-4", children: /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(Button, { type: "button", onClick: handleSave, "data-testid": "save-page", children: "Save Page" }) })
|
|
2256
|
+
] });
|
|
2257
|
+
}
|
|
2258
|
+
PageEditor.displayName = "PageEditor";
|
|
2259
|
+
|
|
2260
|
+
// src/components/layout/admin-layout.tsx
|
|
2261
|
+
var React21 = __toESM(require("react"), 1);
|
|
2262
|
+
var import_jsx_runtime27 = require("react/jsx-runtime");
|
|
2263
|
+
var DEFAULT_NAV_ITEMS = [
|
|
2264
|
+
{ label: "Pages", path: "/pages" },
|
|
2265
|
+
{ label: "Navigation", path: "/navigation" },
|
|
2266
|
+
{ label: "Media", path: "/media" }
|
|
2267
|
+
];
|
|
2268
|
+
function AdminLayout({
|
|
2269
|
+
children,
|
|
2270
|
+
title = "StructCMS",
|
|
2271
|
+
navItems = DEFAULT_NAV_ITEMS,
|
|
2272
|
+
activePath,
|
|
2273
|
+
onNavigate,
|
|
2274
|
+
className
|
|
2275
|
+
}) {
|
|
2276
|
+
const [sidebarOpen, setSidebarOpen] = React21.useState(false);
|
|
2277
|
+
return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("div", { className: cn("flex h-screen bg-background", className), "data-testid": "admin-layout", children: [
|
|
2278
|
+
sidebarOpen && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
|
|
2279
|
+
"div",
|
|
2280
|
+
{
|
|
2281
|
+
className: "fixed inset-0 z-40 bg-black/50 md:hidden",
|
|
2282
|
+
onClick: () => setSidebarOpen(false),
|
|
2283
|
+
onKeyDown: (e) => {
|
|
2284
|
+
if (e.key === "Enter" || e.key === "Escape") {
|
|
2285
|
+
setSidebarOpen(false);
|
|
2286
|
+
}
|
|
2287
|
+
},
|
|
2288
|
+
role: "button",
|
|
2289
|
+
tabIndex: 0,
|
|
2290
|
+
"data-testid": "sidebar-overlay"
|
|
2291
|
+
}
|
|
2292
|
+
),
|
|
2293
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
|
|
2294
|
+
"aside",
|
|
2295
|
+
{
|
|
2296
|
+
className: cn(
|
|
2297
|
+
"fixed inset-y-0 left-0 z-50 w-64 bg-card border-r border-input transform transition-transform duration-200 md:relative md:translate-x-0",
|
|
2298
|
+
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
|
2299
|
+
),
|
|
2300
|
+
"data-testid": "sidebar",
|
|
2301
|
+
children: [
|
|
2302
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: "flex items-center h-14 px-4 border-b border-input", children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("h1", { className: "text-lg font-bold", "data-testid": "sidebar-title", children: title }) }),
|
|
2303
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsx)("nav", { className: "p-2 space-y-1", "data-testid": "sidebar-nav", children: navItems.map((item) => /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
|
|
2304
|
+
"button",
|
|
2305
|
+
{
|
|
2306
|
+
type: "button",
|
|
2307
|
+
className: cn(
|
|
2308
|
+
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
|
|
2309
|
+
activePath === item.path ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
|
2310
|
+
),
|
|
2311
|
+
onClick: () => {
|
|
2312
|
+
onNavigate(item.path);
|
|
2313
|
+
setSidebarOpen(false);
|
|
2314
|
+
},
|
|
2315
|
+
"data-testid": `nav-link-${item.path}`,
|
|
2316
|
+
children: item.label
|
|
2317
|
+
},
|
|
2318
|
+
item.path
|
|
2319
|
+
)) })
|
|
2320
|
+
]
|
|
2321
|
+
}
|
|
2322
|
+
),
|
|
2323
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("div", { className: "flex-1 flex flex-col min-w-0", children: [
|
|
2324
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
|
|
2325
|
+
"header",
|
|
2326
|
+
{
|
|
2327
|
+
className: "flex items-center h-14 px-4 border-b border-input bg-card",
|
|
2328
|
+
"data-testid": "header",
|
|
2329
|
+
children: [
|
|
2330
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
|
|
2331
|
+
Button,
|
|
2332
|
+
{
|
|
2333
|
+
type: "button",
|
|
2334
|
+
variant: "ghost",
|
|
2335
|
+
size: "icon",
|
|
2336
|
+
className: "md:hidden mr-2",
|
|
2337
|
+
onClick: () => setSidebarOpen(!sidebarOpen),
|
|
2338
|
+
"data-testid": "sidebar-toggle",
|
|
2339
|
+
children: "\u2630"
|
|
2340
|
+
}
|
|
2341
|
+
),
|
|
2342
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsx)("h2", { className: "text-lg font-semibold", "data-testid": "header-title", children: title })
|
|
2343
|
+
]
|
|
2344
|
+
}
|
|
2345
|
+
),
|
|
2346
|
+
/* @__PURE__ */ (0, import_jsx_runtime27.jsx)("main", { className: "flex-1 overflow-auto p-6", "data-testid": "main-content", children })
|
|
2347
|
+
] })
|
|
2348
|
+
] });
|
|
2349
|
+
}
|
|
2350
|
+
AdminLayout.displayName = "AdminLayout";
|
|
2351
|
+
|
|
2352
|
+
// src/components/app/struct-cms-admin-app.tsx
|
|
2353
|
+
var import_jsx_runtime28 = require("react/jsx-runtime");
|
|
2354
|
+
function useNavigationData(currentView) {
|
|
2355
|
+
const apiClient = useApiClient();
|
|
2356
|
+
const [navigationData, setNavigationData] = (0, import_react6.useState)(null);
|
|
2357
|
+
const [navigationLoading, setNavigationLoading] = (0, import_react6.useState)(false);
|
|
2358
|
+
const [navigationError, setNavigationError] = (0, import_react6.useState)(null);
|
|
2359
|
+
(0, import_react6.useEffect)(() => {
|
|
2360
|
+
if (currentView.type !== "navigation") {
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
const fetchNavigation = async () => {
|
|
2364
|
+
setNavigationLoading(true);
|
|
2365
|
+
setNavigationError(null);
|
|
2366
|
+
try {
|
|
2367
|
+
const response = await apiClient.get("/navigation/main");
|
|
2368
|
+
if (response.error) {
|
|
2369
|
+
setNavigationError(response.error.message);
|
|
2370
|
+
} else if (response.data) {
|
|
2371
|
+
setNavigationData(response.data);
|
|
2372
|
+
}
|
|
2373
|
+
} catch (err) {
|
|
2374
|
+
setNavigationError("Failed to load navigation");
|
|
2375
|
+
console.error(err);
|
|
2376
|
+
} finally {
|
|
2377
|
+
setNavigationLoading(false);
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
fetchNavigation();
|
|
2381
|
+
}, [currentView.type, apiClient]);
|
|
2382
|
+
return { navigationData, navigationLoading, navigationError, setNavigationData };
|
|
2383
|
+
}
|
|
2384
|
+
function usePageData(currentView) {
|
|
2385
|
+
const apiClient = useApiClient();
|
|
2386
|
+
const [pageData, setPageData] = (0, import_react6.useState)(null);
|
|
2387
|
+
const [pageLoading, setPageLoading] = (0, import_react6.useState)(false);
|
|
2388
|
+
const [pageError, setPageError] = (0, import_react6.useState)(null);
|
|
2389
|
+
(0, import_react6.useEffect)(() => {
|
|
2390
|
+
if (currentView.type !== "page-editor") {
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
const pageId = currentView.pageId;
|
|
2394
|
+
if (!pageId) {
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
const fetchPage = async () => {
|
|
2398
|
+
setPageLoading(true);
|
|
2399
|
+
setPageError(null);
|
|
2400
|
+
try {
|
|
2401
|
+
const response = await apiClient.get(`/pages/id/${pageId}`);
|
|
2402
|
+
if (response.error) {
|
|
2403
|
+
setPageError(response.error.message);
|
|
2404
|
+
} else if (response.data) {
|
|
2405
|
+
setPageData(response.data);
|
|
2406
|
+
}
|
|
2407
|
+
} catch (err) {
|
|
2408
|
+
setPageError("Failed to load page");
|
|
2409
|
+
console.error(err);
|
|
2410
|
+
} finally {
|
|
2411
|
+
setPageLoading(false);
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
fetchPage();
|
|
2415
|
+
}, [currentView, apiClient]);
|
|
2416
|
+
return { pageData, pageLoading, pageError };
|
|
2417
|
+
}
|
|
2418
|
+
function renderPageEditorView(currentView, pageData, pageLoading, pageError, registry, onSave) {
|
|
2419
|
+
if (currentView.type !== "page-editor") return null;
|
|
2420
|
+
if (currentView.pageId && pageLoading) {
|
|
2421
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "space-y-4", children: [
|
|
2422
|
+
/* @__PURE__ */ (0, import_jsx_runtime28.jsx)(Skeleton, { className: "h-8 w-48" }),
|
|
2423
|
+
/* @__PURE__ */ (0, import_jsx_runtime28.jsx)(Skeleton, { className: "h-64 w-full" })
|
|
2424
|
+
] });
|
|
2425
|
+
}
|
|
2426
|
+
if (currentView.pageId && pageError) {
|
|
2427
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "text-red-600", children: [
|
|
2428
|
+
"Error: ",
|
|
2429
|
+
pageError
|
|
2430
|
+
] });
|
|
2431
|
+
}
|
|
2432
|
+
if (currentView.pageId && !pageData) {
|
|
2433
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "text-gray-600", children: "Page not found" });
|
|
2434
|
+
}
|
|
2435
|
+
const sections = pageData?.sections ?? [];
|
|
2436
|
+
const allowedSections = pageData ? registry.getPageType(pageData.pageType)?.allowedSections ?? [] : registry.getAllSections().map((s) => s.name);
|
|
2437
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(PageEditor, { sections, allowedSections, onSave });
|
|
2438
|
+
}
|
|
2439
|
+
function renderNavigationView(navigationData, navigationLoading, navigationError, onSave) {
|
|
2440
|
+
if (navigationLoading) {
|
|
2441
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "space-y-4", children: [
|
|
2442
|
+
/* @__PURE__ */ (0, import_jsx_runtime28.jsx)(Skeleton, { className: "h-8 w-48" }),
|
|
2443
|
+
/* @__PURE__ */ (0, import_jsx_runtime28.jsx)(Skeleton, { className: "h-64 w-full" })
|
|
2444
|
+
] });
|
|
2445
|
+
}
|
|
2446
|
+
if (navigationError) {
|
|
2447
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "text-red-600", children: [
|
|
2448
|
+
"Error: ",
|
|
2449
|
+
navigationError
|
|
2450
|
+
] });
|
|
2451
|
+
}
|
|
2452
|
+
if (!navigationData) {
|
|
2453
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "text-gray-600", children: "No navigation found. Create one via the seed endpoint." });
|
|
2454
|
+
}
|
|
2455
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(NavigationEditor, { items: navigationData.items, onSave });
|
|
2456
|
+
}
|
|
2457
|
+
function ViewRenderer({
|
|
2458
|
+
currentView,
|
|
2459
|
+
registry,
|
|
2460
|
+
customRenderView,
|
|
2461
|
+
onNavigate,
|
|
2462
|
+
onSelectPage,
|
|
2463
|
+
onCreatePage,
|
|
2464
|
+
onUploadMedia
|
|
2465
|
+
}) {
|
|
2466
|
+
const apiClient = useApiClient();
|
|
2467
|
+
const { navigationData, navigationLoading, navigationError, setNavigationData } = useNavigationData(currentView);
|
|
2468
|
+
const { pageData, pageLoading, pageError } = usePageData(currentView);
|
|
2469
|
+
const handleSaveNavigation = async (items) => {
|
|
2470
|
+
if (!navigationData) return;
|
|
2471
|
+
try {
|
|
2472
|
+
const response = await apiClient.put(`/navigation/id/${navigationData.id}`, {
|
|
2473
|
+
items
|
|
2474
|
+
});
|
|
2475
|
+
if (response.error) {
|
|
2476
|
+
console.error("Failed to update navigation:", response.error.message);
|
|
2477
|
+
} else if (response.data) {
|
|
2478
|
+
setNavigationData(response.data);
|
|
2479
|
+
}
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
console.error("Failed to update navigation:", err);
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
const handleSavePage = async (updatedSections) => {
|
|
2485
|
+
if (!pageData) return;
|
|
2486
|
+
try {
|
|
2487
|
+
const response = await apiClient.put(`/pages/id/${pageData.id}`, {
|
|
2488
|
+
title: pageData.title,
|
|
2489
|
+
sections: updatedSections
|
|
2490
|
+
});
|
|
2491
|
+
if (response.error) {
|
|
2492
|
+
console.error("Failed to update page:", response.error.message);
|
|
2493
|
+
} else {
|
|
2494
|
+
onNavigate({ type: "pages" });
|
|
2495
|
+
}
|
|
2496
|
+
} catch (err) {
|
|
2497
|
+
console.error("Failed to update page:", err);
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
if (customRenderView) {
|
|
2501
|
+
const customView = customRenderView(currentView);
|
|
2502
|
+
if (customView !== null) {
|
|
2503
|
+
return customView;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
switch (currentView.type) {
|
|
2507
|
+
case "dashboard":
|
|
2508
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
|
|
2509
|
+
DashboardPage,
|
|
2510
|
+
{
|
|
2511
|
+
onSelectPage,
|
|
2512
|
+
onCreatePage,
|
|
2513
|
+
onUploadMedia
|
|
2514
|
+
}
|
|
2515
|
+
);
|
|
2516
|
+
case "pages":
|
|
2517
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(PageList, { onSelectPage, onCreatePage });
|
|
2518
|
+
case "page-editor":
|
|
2519
|
+
return renderPageEditorView(
|
|
2520
|
+
currentView,
|
|
2521
|
+
pageData,
|
|
2522
|
+
pageLoading,
|
|
2523
|
+
pageError,
|
|
2524
|
+
registry,
|
|
2525
|
+
handleSavePage
|
|
2526
|
+
);
|
|
2527
|
+
case "media":
|
|
2528
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(MediaBrowser, { onSelect: () => {
|
|
2529
|
+
} });
|
|
2530
|
+
case "navigation":
|
|
2531
|
+
return renderNavigationView(
|
|
2532
|
+
navigationData,
|
|
2533
|
+
navigationLoading,
|
|
2534
|
+
navigationError,
|
|
2535
|
+
handleSaveNavigation
|
|
2536
|
+
);
|
|
2537
|
+
case "custom":
|
|
2538
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { "data-testid": "custom-view", children: [
|
|
2539
|
+
"Custom view for path: ",
|
|
2540
|
+
currentView.path
|
|
2541
|
+
] });
|
|
2542
|
+
default:
|
|
2543
|
+
return null;
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
function StructCMSAdminApp({
|
|
2547
|
+
registry,
|
|
2548
|
+
apiBaseUrl = "/api/cms",
|
|
2549
|
+
className,
|
|
2550
|
+
customNavItems = [],
|
|
2551
|
+
renderView: customRenderView,
|
|
2552
|
+
enableAuth = false,
|
|
2553
|
+
onAuthStateChange
|
|
2554
|
+
}) {
|
|
2555
|
+
const [currentView, setCurrentView] = (0, import_react6.useState)({ type: "dashboard" });
|
|
2556
|
+
const handleNavigate = (path) => {
|
|
2557
|
+
if (path === "/") {
|
|
2558
|
+
setCurrentView({ type: "dashboard" });
|
|
2559
|
+
} else if (path === "/pages") {
|
|
2560
|
+
setCurrentView({ type: "pages" });
|
|
2561
|
+
} else if (path === "/media") {
|
|
2562
|
+
setCurrentView({ type: "media" });
|
|
2563
|
+
} else if (path === "/navigation") {
|
|
2564
|
+
setCurrentView({ type: "navigation" });
|
|
2565
|
+
} else {
|
|
2566
|
+
setCurrentView({ type: "custom", path });
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
const handleNavigateToView = (view) => {
|
|
2570
|
+
setCurrentView(view);
|
|
2571
|
+
};
|
|
2572
|
+
const handleSelectPage = (page) => {
|
|
2573
|
+
setCurrentView({ type: "page-editor", pageId: page.id });
|
|
2574
|
+
};
|
|
2575
|
+
const handleCreatePage = () => {
|
|
2576
|
+
setCurrentView({ type: "page-editor" });
|
|
2577
|
+
};
|
|
2578
|
+
const handleUploadMedia = () => {
|
|
2579
|
+
setCurrentView({ type: "media" });
|
|
2580
|
+
};
|
|
2581
|
+
const defaultNavItems = [
|
|
2582
|
+
{ label: "Dashboard", path: "/" },
|
|
2583
|
+
{ label: "Pages", path: "/pages" },
|
|
2584
|
+
{ label: "Navigation", path: "/navigation" },
|
|
2585
|
+
{ label: "Media", path: "/media" }
|
|
2586
|
+
];
|
|
2587
|
+
const navItems = [...defaultNavItems, ...customNavItems];
|
|
2588
|
+
const appContent = /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(AdminProvider, { registry, apiBaseUrl, children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(AdminLayout, { className, navItems, onNavigate: handleNavigate, children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
|
|
2589
|
+
ViewRenderer,
|
|
2590
|
+
{
|
|
2591
|
+
currentView,
|
|
2592
|
+
registry,
|
|
2593
|
+
customRenderView,
|
|
2594
|
+
onNavigate: handleNavigateToView,
|
|
2595
|
+
onSelectPage: handleSelectPage,
|
|
2596
|
+
onCreatePage: handleCreatePage,
|
|
2597
|
+
onUploadMedia: handleUploadMedia
|
|
2598
|
+
}
|
|
2599
|
+
) }) });
|
|
2600
|
+
if (enableAuth) {
|
|
2601
|
+
return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(AuthProvider, { apiBaseUrl, onAuthStateChange, children: appContent });
|
|
2602
|
+
}
|
|
2603
|
+
return appContent;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// src/components/auth/login-form.tsx
|
|
2607
|
+
var import_react7 = require("react");
|
|
2608
|
+
var import_jsx_runtime29 = require("react/jsx-runtime");
|
|
2609
|
+
function LoginForm({ onSuccess, onError }) {
|
|
2610
|
+
const { signIn } = useAuth();
|
|
2611
|
+
const [email, setEmail] = (0, import_react7.useState)("");
|
|
2612
|
+
const [password, setPassword] = (0, import_react7.useState)("");
|
|
2613
|
+
const [error, setError] = (0, import_react7.useState)(null);
|
|
2614
|
+
const [isSubmitting, setIsSubmitting] = (0, import_react7.useState)(false);
|
|
2615
|
+
const handleSubmit = async (e) => {
|
|
2616
|
+
e.preventDefault();
|
|
2617
|
+
setError(null);
|
|
2618
|
+
setIsSubmitting(true);
|
|
2619
|
+
try {
|
|
2620
|
+
await signIn(email, password);
|
|
2621
|
+
onSuccess?.();
|
|
2622
|
+
} catch (err) {
|
|
2623
|
+
const errorMessage = err instanceof Error ? err.message : "Sign in failed";
|
|
2624
|
+
setError(errorMessage);
|
|
2625
|
+
onError?.(err instanceof Error ? err : new Error(errorMessage));
|
|
2626
|
+
} finally {
|
|
2627
|
+
setIsSubmitting(false);
|
|
2628
|
+
}
|
|
2629
|
+
};
|
|
2630
|
+
return /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-4", children: [
|
|
2631
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "space-y-2", children: [
|
|
2632
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsx)(Label, { htmlFor: "email", children: "Email" }),
|
|
2633
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
|
|
2634
|
+
Input,
|
|
2635
|
+
{
|
|
2636
|
+
id: "email",
|
|
2637
|
+
type: "email",
|
|
2638
|
+
value: email,
|
|
2639
|
+
onChange: (e) => setEmail(e.target.value),
|
|
2640
|
+
placeholder: "admin@example.com",
|
|
2641
|
+
required: true,
|
|
2642
|
+
disabled: isSubmitting
|
|
2643
|
+
}
|
|
2644
|
+
)
|
|
2645
|
+
] }),
|
|
2646
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { className: "space-y-2", children: [
|
|
2647
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsx)(Label, { htmlFor: "password", children: "Password" }),
|
|
2648
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
|
|
2649
|
+
Input,
|
|
2650
|
+
{
|
|
2651
|
+
id: "password",
|
|
2652
|
+
type: "password",
|
|
2653
|
+
value: password,
|
|
2654
|
+
onChange: (e) => setPassword(e.target.value),
|
|
2655
|
+
placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
|
2656
|
+
required: true,
|
|
2657
|
+
disabled: isSubmitting
|
|
2658
|
+
}
|
|
2659
|
+
)
|
|
2660
|
+
] }),
|
|
2661
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "text-sm text-red-600 bg-red-50 p-3 rounded-md", children: error }),
|
|
2662
|
+
/* @__PURE__ */ (0, import_jsx_runtime29.jsx)(Button, { type: "submit", className: "w-full", disabled: isSubmitting, children: isSubmitting ? "Signing in..." : "Sign In" })
|
|
2663
|
+
] });
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// src/components/auth/protected-route.tsx
|
|
2667
|
+
var import_jsx_runtime30 = require("react/jsx-runtime");
|
|
2668
|
+
function ProtectedRoute({ children, fallback, loadingFallback }) {
|
|
2669
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
2670
|
+
const disableAuth = typeof process !== "undefined" && process.env?.NEXT_PUBLIC_DISABLE_AUTH === "true";
|
|
2671
|
+
if (disableAuth) {
|
|
2672
|
+
return /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_jsx_runtime30.Fragment, { children });
|
|
2673
|
+
}
|
|
2674
|
+
if (isLoading) {
|
|
2675
|
+
return /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_jsx_runtime30.Fragment, { children: loadingFallback || /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("div", { children: "Loading..." }) });
|
|
2676
|
+
}
|
|
2677
|
+
if (!isAuthenticated) {
|
|
2678
|
+
return /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_jsx_runtime30.Fragment, { children: fallback || /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("div", { children: "Please sign in to access this page." }) });
|
|
2679
|
+
}
|
|
2680
|
+
return /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(import_jsx_runtime30.Fragment, { children });
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// src/components/auth/oauth-button.tsx
|
|
2684
|
+
var import_jsx_runtime31 = require("react/jsx-runtime");
|
|
2685
|
+
function OAuthButton({
|
|
2686
|
+
provider,
|
|
2687
|
+
apiBaseUrl,
|
|
2688
|
+
redirectTo,
|
|
2689
|
+
children,
|
|
2690
|
+
className
|
|
2691
|
+
}) {
|
|
2692
|
+
const handleClick = async () => {
|
|
2693
|
+
try {
|
|
2694
|
+
const response = await fetch(`${apiBaseUrl}/auth/oauth`, {
|
|
2695
|
+
method: "POST",
|
|
2696
|
+
headers: {
|
|
2697
|
+
"Content-Type": "application/json"
|
|
2698
|
+
},
|
|
2699
|
+
body: JSON.stringify({ provider, redirectTo })
|
|
2700
|
+
});
|
|
2701
|
+
if (!response.ok) {
|
|
2702
|
+
throw new Error("OAuth initialization failed");
|
|
2703
|
+
}
|
|
2704
|
+
const { url } = await response.json();
|
|
2705
|
+
window.location.href = url;
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
console.error("OAuth error:", error);
|
|
2708
|
+
}
|
|
2709
|
+
};
|
|
2710
|
+
const providerLabels = {
|
|
2711
|
+
google: "Google",
|
|
2712
|
+
github: "GitHub",
|
|
2713
|
+
gitlab: "GitLab",
|
|
2714
|
+
azure: "Azure",
|
|
2715
|
+
bitbucket: "Bitbucket"
|
|
2716
|
+
};
|
|
2717
|
+
return /* @__PURE__ */ (0, import_jsx_runtime31.jsx)(Button, { type: "button", variant: "outline", onClick: handleClick, className, children: children || `Sign in with ${providerLabels[provider]}` });
|
|
2718
|
+
}
|
|
2719
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2720
|
+
0 && (module.exports = {
|
|
2721
|
+
AdminLayout,
|
|
2722
|
+
AdminProvider,
|
|
2723
|
+
ArrayField,
|
|
2724
|
+
AuthProvider,
|
|
2725
|
+
Button,
|
|
2726
|
+
DashboardPage,
|
|
2727
|
+
Dialog,
|
|
2728
|
+
ErrorBoundary,
|
|
2729
|
+
FormGenerator,
|
|
2730
|
+
ImagePicker,
|
|
2731
|
+
Input,
|
|
2732
|
+
KpiCards,
|
|
2733
|
+
Label,
|
|
2734
|
+
LoginForm,
|
|
2735
|
+
MediaBrowser,
|
|
2736
|
+
NavigationEditor,
|
|
2737
|
+
OAuthButton,
|
|
2738
|
+
ObjectField,
|
|
2739
|
+
PageEditor,
|
|
2740
|
+
PageList,
|
|
2741
|
+
ProtectedRoute,
|
|
2742
|
+
QuickActions,
|
|
2743
|
+
RecentPages,
|
|
2744
|
+
RichTextEditor,
|
|
2745
|
+
SectionEditor,
|
|
2746
|
+
Skeleton,
|
|
2747
|
+
StringInput,
|
|
2748
|
+
StructCMSAdminApp,
|
|
2749
|
+
TextInput,
|
|
2750
|
+
Textarea,
|
|
2751
|
+
ToastProvider,
|
|
2752
|
+
buttonVariants,
|
|
2753
|
+
cn,
|
|
2754
|
+
fieldNameToLabel,
|
|
2755
|
+
resolveFieldType,
|
|
2756
|
+
useAdmin,
|
|
2757
|
+
useApiClient,
|
|
2758
|
+
useAuth,
|
|
2759
|
+
useToast
|
|
2760
|
+
});
|
|
2761
|
+
//# sourceMappingURL=index.cjs.map
|