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