@dyrected/admin 2.6.1 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/admin.css +120 -28
  2. package/dist/components/forms/field-renderer.d.ts +3 -9
  3. package/dist/components/forms/fields/date-picker.d.ts +4 -1
  4. package/dist/components/forms/fields/format-aware-inputs.test.d.ts +1 -0
  5. package/dist/components/forms/fields/text-field.d.ts +1 -1
  6. package/dist/components/forms/fields/url-field.d.ts +1 -1
  7. package/dist/components/media/__tests__/media-library-dialog.test.d.ts +1 -0
  8. package/dist/components/media/storage-notice.d.ts +7 -0
  9. package/dist/controllers/__tests__/field.test.d.ts +1 -0
  10. package/dist/controllers/__tests__/form.test.d.ts +1 -0
  11. package/dist/controllers/__tests__/media.test.d.ts +1 -0
  12. package/dist/controllers/__tests__/theme.test.d.ts +1 -0
  13. package/dist/controllers/field.d.ts +13 -0
  14. package/dist/controllers/form.d.ts +102 -0
  15. package/dist/controllers/media.d.ts +119 -0
  16. package/dist/controllers/theme.d.ts +28 -0
  17. package/dist/favicon.ico +0 -0
  18. package/dist/favicon.svg +85 -1
  19. package/dist/hooks/__tests__/use-add-media-from-url.test.d.ts +1 -0
  20. package/dist/hooks/__tests__/use-field.test.d.ts +1 -0
  21. package/dist/hooks/admin-theme-context.d.ts +2 -8
  22. package/dist/hooks/admin-theme-provider.d.ts +3 -1
  23. package/dist/hooks/admin-theme.d.ts +1 -0
  24. package/dist/hooks/use-add-media-from-url.d.ts +8 -17
  25. package/dist/hooks/use-admin-theme.d.ts +2 -1
  26. package/dist/hooks/use-dyrected-form.d.ts +2 -0
  27. package/dist/hooks/use-field.d.ts +2 -0
  28. package/dist/hooks/use-media-library.d.ts +26 -0
  29. package/dist/hooks/use-media-upload.d.ts +20 -0
  30. package/dist/hooks/use-media-url.d.ts +20 -0
  31. package/dist/index.d.ts +30 -3
  32. package/dist/index.mjs +1438 -1556
  33. package/dist/lib/__tests__/external-media.test.d.ts +1 -0
  34. package/dist/lib/__tests__/media-utils.test.d.ts +1 -0
  35. package/dist/lib/compress-image.d.ts +9 -0
  36. package/dist/lib/external-media.d.ts +9 -4
  37. package/dist/lib/media-utils.d.ts +26 -0
  38. package/dist/lib/workflow-ui.d.ts +1 -0
  39. package/dist/providers/dyrected-form-context.d.ts +14 -0
  40. package/dist/public/contracts.d.ts +230 -0
  41. package/dist/public/index.d.ts +26 -0
  42. package/dist/public/index.js +2 -0
  43. package/dist/public.d.ts +2 -0
  44. package/dist/types/admin-components.d.ts +17 -2
  45. package/dist/use-add-media-from-url-DHGAAVLY.js +1604 -0
  46. package/package.json +9 -3
@@ -0,0 +1,1604 @@
1
+ import * as React$1 from "react";
2
+ import { createContext, useContext } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { clsx } from "clsx";
5
+ import { extendTailwindMerge } from "tailwind-merge";
6
+ import * as z from "zod";
7
+ //#region src/providers/dyrected-context.ts
8
+ var DyrectedContext = createContext(void 0);
9
+ function useDyrected() {
10
+ const context = useContext(DyrectedContext);
11
+ if (!context) throw new Error("useDyrected must be used within a DyrectedProvider");
12
+ return context;
13
+ }
14
+ //#endregion
15
+ //#region src/lib/utils.ts
16
+ var customTwMerge = extendTailwindMerge({ prefix: "dy-" });
17
+ /**
18
+ * Merges Tailwind class names, resolving conflicts with the `dy-` prefix.
19
+ * Drop-in replacement for `clsx` that handles Dyrected's scoped Tailwind build.
20
+ */
21
+ function cn(...inputs) {
22
+ return customTwMerge(clsx(inputs));
23
+ }
24
+ /**
25
+ * Resolves a media field value to an absolute URL.
26
+ *
27
+ * Handles three input shapes:
28
+ * - A fully-qualified URL (`https://...`) — returned as-is.
29
+ * - A root-relative path (`/uploads/...`) — origin prepended from `baseUrl`.
30
+ * - A bare filename or storage path (`default/photo.jpg`) — prefixed with `/api/media/`.
31
+ *
32
+ * Bare strings that look like document IDs (no extension, no slash) are returned
33
+ * as an empty string so they are not treated as media assets.
34
+ *
35
+ * @param val - A media field value: a URL string, storage path, or a document object with a `url` or `filename` property.
36
+ * @param baseUrl - The Dyrected backend base URL, used to build the origin for relative paths.
37
+ * @returns A fully-qualified URL string, or `""` if the value cannot be resolved.
38
+ */
39
+ function getMediaUrl(val, baseUrl) {
40
+ if (!val) return "";
41
+ let baseOrigin = "";
42
+ if (baseUrl) if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) try {
43
+ baseOrigin = new URL(baseUrl).origin;
44
+ } catch {
45
+ const match = baseUrl.match(/^(https?:\/\/[^\/]+)/);
46
+ baseOrigin = match ? match[1] : baseUrl;
47
+ }
48
+ else baseOrigin = baseUrl;
49
+ const prependBase = (urlPath) => {
50
+ if (urlPath.startsWith("http://") || urlPath.startsWith("https://")) return urlPath;
51
+ const cleanPath = urlPath.startsWith("/") ? urlPath : "/" + urlPath;
52
+ if (cleanPath.startsWith("/uploads/")) return `${baseOrigin.endsWith("/") ? baseOrigin.slice(0, -1) : baseOrigin}${cleanPath}`;
53
+ const basePrefix = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
54
+ try {
55
+ const baseObj = new URL(basePrefix);
56
+ const basePathname = baseObj.pathname === "/" ? "" : baseObj.pathname;
57
+ if (basePathname && cleanPath.startsWith(basePathname)) return `${baseObj.origin}${cleanPath}`;
58
+ } catch {
59
+ if (basePrefix && cleanPath.startsWith(basePrefix)) return cleanPath;
60
+ }
61
+ return `${basePrefix}${cleanPath}`;
62
+ };
63
+ let targetUrl = "";
64
+ if (typeof val === "object" && val !== null) targetUrl = val.url || val.filename || "";
65
+ else targetUrl = String(val);
66
+ if (!targetUrl) return "";
67
+ if (targetUrl.startsWith("http://") || targetUrl.startsWith("https://") || targetUrl.startsWith("/")) return targetUrl;
68
+ if (!targetUrl.includes("/") && !/\.[a-z0-9]+($|\?)/i.test(targetUrl)) return "";
69
+ return prependBase(`/api/media/${targetUrl}`);
70
+ }
71
+ /**
72
+ * Strips the directory prefix from a filename / storage path, returning only the last component (the actual filename).
73
+ * E.g., "dyrected_cloud/j95sv/bcuul/WhatsApp Image 2026-06-20 at 12" -> "WhatsApp Image 2026-06-20 at 12"
74
+ */
75
+ function getDisplayFilename(filename) {
76
+ if (!filename) return "";
77
+ const parts = filename.split("/");
78
+ return parts[parts.length - 1];
79
+ }
80
+ /**
81
+ * Resolves the site URL, overriding it with the current origin during local development on localhost/127.0.0.1.
82
+ */
83
+ function getSiteUrl(configuredSiteUrl) {
84
+ if (typeof window !== "undefined") {
85
+ if (window.location.pathname.startsWith("/sites/")) return configuredSiteUrl || "";
86
+ if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") return window.location.origin;
87
+ }
88
+ return configuredSiteUrl || (typeof window !== "undefined" ? window.location.origin : "");
89
+ }
90
+ //#endregion
91
+ //#region src/hooks/admin-theme.ts
92
+ function resolveAdminTheme(preference, systemTheme) {
93
+ return preference === "system" ? systemTheme : preference;
94
+ }
95
+ function adminThemeClassName(resolvedTheme) {
96
+ return resolvedTheme === "dark" ? "dy-admin-ui dark" : "dy-admin-ui";
97
+ }
98
+ function getSystemAdminTheme() {
99
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light";
100
+ return window.matchMedia("(prefers-color-scheme: dark)")?.matches ? "dark" : "light";
101
+ }
102
+ //#endregion
103
+ //#region src/hooks/admin-theme-context.ts
104
+ var AdminThemeContext = React$1.createContext(null);
105
+ //#endregion
106
+ //#region src/hooks/use-admin-theme.ts
107
+ function useAdminTheme() {
108
+ const context = React$1.useContext(AdminThemeContext);
109
+ if (!context) {
110
+ const resolvedTheme = getSystemAdminTheme();
111
+ return {
112
+ theme: "system",
113
+ systemTheme: resolvedTheme,
114
+ resolvedTheme,
115
+ setTheme: () => void 0,
116
+ themeClassName: adminThemeClassName(resolvedTheme),
117
+ controller: null
118
+ };
119
+ }
120
+ const state = React$1.useSyncExternalStore(context.subscribe, context.getState, context.getState);
121
+ return React$1.useMemo(() => ({
122
+ ...state,
123
+ setTheme: context.setTheme,
124
+ controller: context
125
+ }), [
126
+ context,
127
+ context.setTheme,
128
+ state
129
+ ]);
130
+ }
131
+ //#endregion
132
+ //#region src/lib/media-utils.ts
133
+ /**
134
+ * Safely resolves the collection slug to use for media uploads.
135
+ * If the target collection is upload-enabled, returns collectionSlug.
136
+ * Otherwise, safely falls back to "media".
137
+ */
138
+ function resolveActiveMediaCollection(schemas, collectionSlug) {
139
+ if (!collectionSlug) return "media";
140
+ if ((schemas?.collections?.find((c) => c.slug === collectionSlug))?.upload) return collectionSlug;
141
+ return "media";
142
+ }
143
+ /**
144
+ * Determines whether a media item is an External asset (embed/remote link) or an
145
+ * Internal asset (uploaded file in storage) based strictly on its persisted metadata.
146
+ */
147
+ function getMediaSourceInfo(item) {
148
+ if (!item || typeof item !== "object") return {
149
+ source: "internal",
150
+ type: "storage",
151
+ label: "Uploaded Asset"
152
+ };
153
+ const obj = item;
154
+ const mimeType = String(obj.mimeType || "");
155
+ const filesize = typeof obj.filesize === "number" ? obj.filesize : typeof obj.size === "number" ? obj.size : void 0;
156
+ const urlStr = String(obj.url || "");
157
+ const isRemoteUrl = urlStr.startsWith("http://") || urlStr.startsWith("https://");
158
+ if (mimeType === "video/youtube") return {
159
+ source: "external",
160
+ type: "youtube",
161
+ label: "YouTube Embed"
162
+ };
163
+ if (mimeType === "video/vimeo") return {
164
+ source: "external",
165
+ type: "vimeo",
166
+ label: "Vimeo Embed"
167
+ };
168
+ if (mimeType === "image/external") return {
169
+ source: "external",
170
+ type: "external-cdn",
171
+ label: "External Image"
172
+ };
173
+ if (mimeType === "video/external" || mimeType.startsWith("external/") || mimeType.startsWith("video/") && filesize === 0 && isRemoteUrl) return {
174
+ source: "external",
175
+ type: "external-cdn",
176
+ label: "External Video"
177
+ };
178
+ if (mimeType === "application/external" || filesize === 0 && isRemoteUrl) return {
179
+ source: "external",
180
+ type: "external-cdn",
181
+ label: "External Asset"
182
+ };
183
+ return {
184
+ source: "internal",
185
+ type: "storage",
186
+ label: "Uploaded Asset"
187
+ };
188
+ }
189
+ /** Returns true if the given item is an external asset. */
190
+ function isExternalMedia(item) {
191
+ return getMediaSourceInfo(item).source === "external";
192
+ }
193
+ /**
194
+ * Checks whether an error indicates that Dyrected media storage is not configured.
195
+ */
196
+ function isStorageNotConfiguredError(error) {
197
+ if (!error) return false;
198
+ const lower = (typeof error === "string" ? error : error?.message || error?.description || String(error)).toLowerCase();
199
+ return lower.includes("storage not configured") || lower.includes("storage is not configured");
200
+ }
201
+ /**
202
+ * Formats a media error into a friendly, non-technical message.
203
+ */
204
+ function formatMediaErrorMessage(error) {
205
+ if (isStorageNotConfiguredError(error)) return "Media storage is not configured yet. Please ask your developer to set up a storage provider in Dyrected.";
206
+ if (error instanceof Error) return error.message;
207
+ if (typeof error === "string") return error;
208
+ return error?.message || "An unexpected media error occurred";
209
+ }
210
+ //#endregion
211
+ //#region src/hooks/use-preferences.ts
212
+ function arePreferenceValuesEqual(a, b) {
213
+ if (Object.is(a, b)) return true;
214
+ try {
215
+ return JSON.stringify(a) === JSON.stringify(b);
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+ /**
221
+ * A hook to manage and persist user preferences in the Admin UI.
222
+ * Uses localStorage for immediate persistence and syncs to the user profile
223
+ * when an authenticated Dyrected client is available.
224
+ */
225
+ function usePreferences(key, defaultValue) {
226
+ const { client, user } = useDyrected();
227
+ const localStorageKey = `dyrected_pref_${key}`;
228
+ const localWriteVersionRef = React$1.useRef(0);
229
+ const defaultValueRef = React$1.useRef(defaultValue);
230
+ defaultValueRef.current = defaultValue;
231
+ const [value, setValue] = React$1.useState(() => {
232
+ if (typeof window === "undefined") return defaultValue;
233
+ try {
234
+ const stored = window.localStorage.getItem(localStorageKey);
235
+ return stored ? JSON.parse(stored) : defaultValue;
236
+ } catch (e) {
237
+ console.warn(`[usePreferences] Error loading key "${key}":`, e);
238
+ return defaultValue;
239
+ }
240
+ });
241
+ React$1.useEffect(() => {
242
+ if (typeof window === "undefined") return;
243
+ try {
244
+ const stored = window.localStorage.getItem(localStorageKey);
245
+ const nextValue = stored ? JSON.parse(stored) : defaultValueRef.current;
246
+ setValue((prev) => arePreferenceValuesEqual(prev, nextValue) ? prev : nextValue);
247
+ } catch (e) {
248
+ console.warn(`[usePreferences] Error syncing key "${key}":`, e);
249
+ }
250
+ }, [key, localStorageKey]);
251
+ React$1.useEffect(() => {
252
+ if (!client || !user) return;
253
+ let cancelled = false;
254
+ const writeVersion = localWriteVersionRef.current;
255
+ client.getPreference(key).then((result) => {
256
+ if (cancelled || result.value === null || localWriteVersionRef.current !== writeVersion) return;
257
+ setValue((prev) => arePreferenceValuesEqual(prev, result.value) ? prev : result.value);
258
+ if (typeof window !== "undefined") window.localStorage.setItem(localStorageKey, JSON.stringify(result.value));
259
+ }).catch((e) => {
260
+ console.warn(`[usePreferences] Error loading remote key "${key}":`, e);
261
+ });
262
+ return () => {
263
+ cancelled = true;
264
+ };
265
+ }, [
266
+ client,
267
+ user,
268
+ key,
269
+ localStorageKey
270
+ ]);
271
+ return [value, React$1.useCallback((updater) => {
272
+ setValue((prev) => {
273
+ localWriteVersionRef.current += 1;
274
+ const newValue = typeof updater === "function" ? updater(prev) : updater;
275
+ if (arePreferenceValuesEqual(prev, newValue)) return prev;
276
+ if (typeof window !== "undefined") try {
277
+ window.localStorage.setItem(localStorageKey, JSON.stringify(newValue));
278
+ } catch (e) {
279
+ console.warn(`[usePreferences] Error saving key "${key}":`, e);
280
+ }
281
+ if (client && user) client.setPreference(key, newValue).catch((e) => {
282
+ console.warn(`[usePreferences] Error saving remote key "${key}":`, e);
283
+ });
284
+ return newValue;
285
+ });
286
+ }, [
287
+ client,
288
+ user,
289
+ key,
290
+ localStorageKey
291
+ ])];
292
+ }
293
+ //#endregion
294
+ //#region src/lib/external-media.ts
295
+ var YOUTUBE_RE = /(?:youtu\.be\/|youtube\.com\/(?:v\/|u\/\w\/|embed\/|watch\?v=))([^#&?]*)/;
296
+ var VIMEO_RE = /vimeo\.com\/(?:video\/)?([0-9]+)/;
297
+ var IMAGE_RE = /\.(jpeg|jpg|gif|png|webp|svg|avif)(?:\?.*)?$/i;
298
+ var VIDEO_RE = /\.(mp4|webm|ogg|mov|m4v|mkv)(?:\?.*)?$/i;
299
+ /** Extract the trailing filename from a URL, stripping any query string. */
300
+ function filenameFromUrl(url, fallback) {
301
+ return url.split("/").pop()?.split("?")[0] || fallback;
302
+ }
303
+ /** Check if a URL points to an embeddable video streaming site (YouTube or Vimeo). */
304
+ function isEmbeddableVideoUrl(url) {
305
+ const trimmed = url.trim();
306
+ return YOUTUBE_RE.test(trimmed) || VIMEO_RE.test(trimmed);
307
+ }
308
+ /** Check if a URL points directly to a raster/vector image file. */
309
+ function isDirectImageUrl(url) {
310
+ return IMAGE_RE.test(url.trim());
311
+ }
312
+ /**
313
+ * Build a media document payload from an external URL.
314
+ * Returns `null` when the input is empty/whitespace.
315
+ */
316
+ function buildExternalMediaPayload(rawUrl) {
317
+ const url = rawUrl.trim();
318
+ if (!url) return null;
319
+ const yt = url.match(YOUTUBE_RE);
320
+ if (yt && yt[1]) return {
321
+ filename: `YouTube: ${yt[1]}`,
322
+ url,
323
+ mimeType: "video/youtube",
324
+ filesize: 0,
325
+ id: `yt_${yt[1]}`
326
+ };
327
+ const vimeo = url.match(VIMEO_RE);
328
+ if (vimeo && vimeo[1]) return {
329
+ filename: `Vimeo: ${vimeo[1]}`,
330
+ url,
331
+ mimeType: "video/vimeo",
332
+ filesize: 0,
333
+ id: `vm_${vimeo[1]}`
334
+ };
335
+ if (IMAGE_RE.test(url)) return {
336
+ filename: filenameFromUrl(url, "External Image"),
337
+ url,
338
+ mimeType: "image/external",
339
+ filesize: 0,
340
+ id: `img_${Math.random().toString(36).substring(7)}`
341
+ };
342
+ if (VIDEO_RE.test(url)) {
343
+ const ext = url.split(".").pop()?.split("?")[0] || "mp4";
344
+ return {
345
+ filename: filenameFromUrl(url, "External Video"),
346
+ url,
347
+ mimeType: `video/${ext === "mov" ? "quicktime" : ext}`,
348
+ filesize: 0,
349
+ id: `vid_${Math.random().toString(36).substring(7)}`
350
+ };
351
+ }
352
+ return {
353
+ filename: filenameFromUrl(url, "External File"),
354
+ url,
355
+ mimeType: "application/external",
356
+ filesize: 0,
357
+ id: `ext_${Math.random().toString(36).substring(7)}`
358
+ };
359
+ }
360
+ /**
361
+ * Resolves the thumbnail/preview image URL for any media item — external or uploaded.
362
+ *
363
+ * This is the single source of truth used by every media surface (grid, picker,
364
+ * library dialog) so YouTube, Vimeo, external images, and uploaded files all render
365
+ * consistently everywhere.
366
+ * - `video/youtube` → the YouTube thumbnail for the video id
367
+ * - `video/vimeo` → the Vimeo logo placeholder
368
+ * - `image/external`→ the external URL itself
369
+ * - anything else → resolved through {@link getMediaUrl}
370
+ */
371
+ function getMediaPreviewUrl(item, baseUrl) {
372
+ if (!item) return "";
373
+ const obj = typeof item === "object" && item !== null ? item : null;
374
+ const mimeType = obj ? obj.mimeType : void 0;
375
+ const url = obj ? obj.url : void 0;
376
+ if (mimeType === "video/youtube") {
377
+ const match = url?.match(YOUTUBE_RE);
378
+ const videoId = match && match[1];
379
+ return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : "";
380
+ }
381
+ if (mimeType === "video/vimeo") return "https://vimeo.com/assets/images/logo_vimeo_blue.png";
382
+ if (mimeType === "image/external") return url || "";
383
+ return getMediaUrl(item, baseUrl);
384
+ }
385
+ /**
386
+ * Returns the player embed URL for an external video item (YouTube/Vimeo), or
387
+ * `null` for anything that isn't an embeddable video. Used to render an inline
388
+ * iframe player instead of a static thumbnail.
389
+ */
390
+ function getVideoEmbedUrl(item) {
391
+ const obj = typeof item === "object" && item !== null ? item : null;
392
+ if (!obj) return null;
393
+ const mimeType = obj.mimeType;
394
+ const url = obj.url || "";
395
+ if (mimeType === "video/youtube") {
396
+ const m = url.match(YOUTUBE_RE);
397
+ return m && m[1] ? `https://www.youtube.com/embed/${m[1]}` : null;
398
+ }
399
+ if (mimeType === "video/vimeo") {
400
+ const m = url.match(VIMEO_RE);
401
+ return m && m[1] ? `https://player.vimeo.com/video/${m[1]}` : null;
402
+ }
403
+ return null;
404
+ }
405
+ //#endregion
406
+ //#region src/lib/compress-image.ts
407
+ /**
408
+ * Compress a raster image file client-side using the Canvas API before upload.
409
+ * - Skips SVGs, GIFs, and non-images (returned unchanged).
410
+ * - Skips if the environment lacks DOM Canvas / URL API (SSR or Node.js test environment).
411
+ * - Skips if the result would be larger than the original.
412
+ * - maxDimension: longest edge cap in pixels (default 2048).
413
+ * - quality: JPEG/WebP quality 0–1 (default 0.85).
414
+ */
415
+ async function compressImage(file, maxDimension = 2048, quality = .85) {
416
+ if (!file.type.startsWith("image/") || file.type === "image/svg+xml" || file.type === "image/gif" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function" || typeof Image === "undefined") return file;
417
+ return new Promise((resolve) => {
418
+ try {
419
+ const img = new Image();
420
+ const objectUrl = URL.createObjectURL(file);
421
+ img.onerror = () => {
422
+ if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(objectUrl);
423
+ resolve(file);
424
+ };
425
+ img.onload = () => {
426
+ if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(objectUrl);
427
+ let { width, height } = img;
428
+ if (width > maxDimension || height > maxDimension) {
429
+ const ratio = Math.min(maxDimension / width, maxDimension / height);
430
+ width = Math.round(width * ratio);
431
+ height = Math.round(height * ratio);
432
+ }
433
+ const canvas = document.createElement("canvas");
434
+ canvas.width = width;
435
+ canvas.height = height;
436
+ const ctx = canvas.getContext("2d");
437
+ if (!ctx) {
438
+ resolve(file);
439
+ return;
440
+ }
441
+ ctx.drawImage(img, 0, 0, width, height);
442
+ const outputType = file.type === "image/png" ? "image/png" : "image/jpeg";
443
+ canvas.toBlob((blob) => {
444
+ if (!blob || blob.size >= file.size) {
445
+ resolve(file);
446
+ return;
447
+ }
448
+ resolve(new File([blob], file.name, {
449
+ type: outputType,
450
+ lastModified: Date.now()
451
+ }));
452
+ }, outputType, quality);
453
+ };
454
+ img.src = objectUrl;
455
+ } catch {
456
+ resolve(file);
457
+ }
458
+ });
459
+ }
460
+ //#endregion
461
+ //#region src/controllers/media.ts
462
+ function createStore(initialState) {
463
+ let state = initialState;
464
+ const listeners = /* @__PURE__ */ new Set();
465
+ return {
466
+ getState: () => state,
467
+ setState: (updater) => {
468
+ state = typeof updater === "function" ? updater(state) : updater;
469
+ listeners.forEach((listener) => listener());
470
+ },
471
+ subscribe: (listener) => {
472
+ listeners.add(listener);
473
+ return () => listeners.delete(listener);
474
+ }
475
+ };
476
+ }
477
+ function ensureClient(client) {
478
+ if (!client) throw new Error("SDK Client not initialized");
479
+ return client;
480
+ }
481
+ function randomId(prefix) {
482
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
483
+ }
484
+ async function maybeCompressImage(file, compressImages, maxDimension, quality) {
485
+ if (!compressImages) return file;
486
+ return compressImage(file, maxDimension, quality);
487
+ }
488
+ function createMediaUploadController({ client, schemas, collection, compressImages = true, maxDimension = 2048, quality = .85, onCompletedItem, onAllCompleted, onError }) {
489
+ const activeCollection = resolveActiveMediaCollection(schemas, collection);
490
+ const store = createStore({
491
+ activeCollection,
492
+ isUploading: false,
493
+ queue: []
494
+ });
495
+ const processFile = async (item) => {
496
+ const sdkClient = ensureClient(client);
497
+ store.setState((state) => ({
498
+ ...state,
499
+ queue: state.queue.map((queuedItem) => queuedItem.id === item.id ? {
500
+ ...queuedItem,
501
+ status: "uploading",
502
+ progress: 0,
503
+ error: void 0
504
+ } : queuedItem)
505
+ }));
506
+ try {
507
+ const processedFile = await maybeCompressImage(item.file, compressImages, maxDimension, quality);
508
+ store.setState((state) => ({
509
+ ...state,
510
+ queue: state.queue.map((queuedItem) => queuedItem.id === item.id ? {
511
+ ...queuedItem,
512
+ compressedSize: processedFile.size
513
+ } : queuedItem)
514
+ }));
515
+ const result = await sdkClient.collection(activeCollection).upload(processedFile, void 0, { onProgress: (progress) => {
516
+ store.setState((state) => ({
517
+ ...state,
518
+ queue: state.queue.map((queuedItem) => queuedItem.id === item.id ? {
519
+ ...queuedItem,
520
+ progress
521
+ } : queuedItem)
522
+ }));
523
+ } });
524
+ const completedItem = {
525
+ ...item,
526
+ compressedSize: processedFile.size,
527
+ progress: 100,
528
+ status: "completed",
529
+ result
530
+ };
531
+ store.setState((state) => ({
532
+ ...state,
533
+ queue: state.queue.map((queuedItem) => queuedItem.id === item.id ? completedItem : queuedItem)
534
+ }));
535
+ await onCompletedItem?.(result);
536
+ return completedItem;
537
+ } catch (error) {
538
+ const message = formatMediaErrorMessage(error);
539
+ const normalizedError = new Error(message);
540
+ const failedItem = {
541
+ ...item,
542
+ status: "error",
543
+ error: message
544
+ };
545
+ store.setState((state) => ({
546
+ ...state,
547
+ queue: state.queue.map((queuedItem) => queuedItem.id === item.id ? failedItem : queuedItem)
548
+ }));
549
+ onError?.(normalizedError, item.file);
550
+ return failedItem;
551
+ }
552
+ };
553
+ return {
554
+ getState: store.getState,
555
+ subscribe: store.subscribe,
556
+ async uploadFiles(files) {
557
+ if (files.length === 0) return [];
558
+ const queueItems = files.map((file, index) => ({
559
+ id: randomId(`upload_${index}`),
560
+ file,
561
+ originalSize: file.size,
562
+ compressedSize: file.size,
563
+ progress: 0,
564
+ status: "queued"
565
+ }));
566
+ store.setState((state) => ({
567
+ ...state,
568
+ isUploading: true,
569
+ queue: [...state.queue, ...queueItems]
570
+ }));
571
+ const completedItems = [];
572
+ for (const queueItem of queueItems) {
573
+ const result = await processFile(queueItem);
574
+ if (result.status === "completed" && result.result) completedItems.push(result.result);
575
+ }
576
+ store.setState((state) => ({
577
+ ...state,
578
+ isUploading: false
579
+ }));
580
+ if (completedItems.length > 0) await onAllCompleted?.(completedItems);
581
+ return completedItems;
582
+ },
583
+ async retryUpload(id) {
584
+ const queueItem = store.getState().queue.find((item) => item.id === id);
585
+ if (!queueItem) return null;
586
+ store.setState((state) => ({
587
+ ...state,
588
+ isUploading: true
589
+ }));
590
+ const result = await processFile(queueItem);
591
+ store.setState((state) => ({
592
+ ...state,
593
+ isUploading: false
594
+ }));
595
+ return result.result ?? null;
596
+ },
597
+ removeQueueItem(id) {
598
+ store.setState((state) => ({
599
+ ...state,
600
+ queue: state.queue.filter((item) => item.id !== id)
601
+ }));
602
+ },
603
+ clearCompleted() {
604
+ store.setState((state) => ({
605
+ ...state,
606
+ queue: state.queue.filter((item) => item.status !== "completed")
607
+ }));
608
+ }
609
+ };
610
+ }
611
+ function createMediaURLController({ client, schemas, collection, compressImages = true, maxDimension = 2048, quality = .85, onAdded, onError }) {
612
+ const activeCollection = resolveActiveMediaCollection(schemas, collection);
613
+ const store = createStore({
614
+ activeCollection,
615
+ isSubmitting: false
616
+ });
617
+ const classifyURL = (url) => {
618
+ const trimmed = url.trim();
619
+ const payload = buildExternalMediaPayload(trimmed);
620
+ if (!payload) return {
621
+ kind: "unknown",
622
+ payload: null
623
+ };
624
+ if (payload.mimeType === "video/youtube") return {
625
+ kind: "youtube",
626
+ payload
627
+ };
628
+ if (payload.mimeType === "video/vimeo") return {
629
+ kind: "vimeo",
630
+ payload
631
+ };
632
+ if (payload.mimeType === "image/external" || isDirectImageUrl(trimmed)) return {
633
+ kind: "direct-image",
634
+ payload
635
+ };
636
+ if (payload.mimeType.startsWith("video/")) return {
637
+ kind: "direct-video",
638
+ payload
639
+ };
640
+ if (payload.mimeType === "application/external") return {
641
+ kind: "generic-file",
642
+ payload
643
+ };
644
+ return {
645
+ kind: "unknown",
646
+ payload
647
+ };
648
+ };
649
+ return {
650
+ getState: store.getState,
651
+ subscribe: store.subscribe,
652
+ classifyURL,
653
+ async importURL(url) {
654
+ const sdkClient = ensureClient(client);
655
+ const trimmed = url.trim();
656
+ const { payload } = classifyURL(trimmed);
657
+ if (!trimmed || !payload) throw new Error("A valid media URL is required");
658
+ store.setState((state) => ({
659
+ ...state,
660
+ isSubmitting: true
661
+ }));
662
+ try {
663
+ let result;
664
+ if (isEmbeddableVideoUrl(trimmed)) result = await sdkClient.collection(activeCollection).create(payload);
665
+ else if (payload.mimeType.startsWith("video/") || payload.mimeType === "application/external") result = await sdkClient.collection(activeCollection).create(payload);
666
+ else if (isDirectImageUrl(trimmed) || payload.mimeType === "image/external") try {
667
+ const response = await fetch(trimmed);
668
+ if (!response.ok) throw new Error(`HTTP error ${response.status}`);
669
+ const blob = await response.blob();
670
+ const processedFile = await maybeCompressImage(new File([blob], filenameFromUrl(trimmed, "image.jpg"), { type: blob.type || "image/jpeg" }), compressImages, maxDimension, quality);
671
+ result = await sdkClient.collection(activeCollection).upload(processedFile, void 0);
672
+ } catch (fetchError) {
673
+ console.warn("Direct image fetch failed (e.g. CORS). Falling back to external CDN reference:", fetchError);
674
+ result = await sdkClient.collection(activeCollection).create(payload);
675
+ }
676
+ else result = await sdkClient.collection(activeCollection).create(payload);
677
+ await onAdded?.(result);
678
+ return result;
679
+ } catch (error) {
680
+ const message = formatMediaErrorMessage(error);
681
+ const normalizedError = new Error(message);
682
+ console.error("Failed to add media from URL", normalizedError);
683
+ onError?.(normalizedError);
684
+ throw normalizedError;
685
+ } finally {
686
+ store.setState((state) => ({
687
+ ...state,
688
+ isSubmitting: false
689
+ }));
690
+ }
691
+ }
692
+ };
693
+ }
694
+ function createMediaLibraryController({ client, schemas, collection, pageSize = 12, initialSearchQuery = "", initialSelectedIds = [] }) {
695
+ const activeCollection = resolveActiveMediaCollection(schemas, collection);
696
+ const store = createStore({
697
+ activeCollection,
698
+ items: [],
699
+ selectedIds: initialSelectedIds,
700
+ searchQuery: initialSearchQuery,
701
+ page: 1,
702
+ hasNextPage: false,
703
+ isLoading: false,
704
+ error: null
705
+ });
706
+ const loadPage = async (page, append) => {
707
+ const sdkClient = ensureClient(client);
708
+ const currentState = store.getState();
709
+ store.setState({
710
+ ...currentState,
711
+ isLoading: true,
712
+ error: null,
713
+ page
714
+ });
715
+ try {
716
+ const response = await sdkClient.listMedia({
717
+ where: currentState.searchQuery ? { filename: { contains: currentState.searchQuery } } : void 0,
718
+ limit: pageSize,
719
+ page
720
+ }, activeCollection);
721
+ const docs = response.docs;
722
+ store.setState((state) => ({
723
+ ...state,
724
+ items: append ? [...state.items, ...docs] : docs,
725
+ hasNextPage: Boolean(response.hasNextPage),
726
+ isLoading: false,
727
+ error: null
728
+ }));
729
+ return docs;
730
+ } catch (error) {
731
+ const message = formatMediaErrorMessage(error);
732
+ const normalizedError = new Error(message);
733
+ store.setState((state) => ({
734
+ ...state,
735
+ isLoading: false,
736
+ error: normalizedError
737
+ }));
738
+ throw normalizedError;
739
+ }
740
+ };
741
+ return {
742
+ getState: store.getState,
743
+ subscribe: store.subscribe,
744
+ load() {
745
+ return loadPage(1, false);
746
+ },
747
+ search(query) {
748
+ store.setState((state) => ({
749
+ ...state,
750
+ searchQuery: query,
751
+ page: 1
752
+ }));
753
+ return loadPage(1, false);
754
+ },
755
+ loadNextPage() {
756
+ const state = store.getState();
757
+ if (state.isLoading || !state.hasNextPage) return Promise.resolve([]);
758
+ return loadPage(state.page + 1, true);
759
+ },
760
+ setSelectedIds(ids) {
761
+ store.setState((state) => ({
762
+ ...state,
763
+ selectedIds: ids
764
+ }));
765
+ },
766
+ select(id) {
767
+ store.setState((state) => ({
768
+ ...state,
769
+ selectedIds: state.selectedIds.includes(id) ? state.selectedIds : [...state.selectedIds, id]
770
+ }));
771
+ },
772
+ deselect(id) {
773
+ store.setState((state) => ({
774
+ ...state,
775
+ selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id)
776
+ }));
777
+ },
778
+ toggle(id) {
779
+ store.setState((state) => ({
780
+ ...state,
781
+ selectedIds: state.selectedIds.includes(id) ? state.selectedIds.filter((selectedId) => selectedId !== id) : [...state.selectedIds, id]
782
+ }));
783
+ },
784
+ clearSelection() {
785
+ store.setState((state) => ({
786
+ ...state,
787
+ selectedIds: []
788
+ }));
789
+ }
790
+ };
791
+ }
792
+ //#endregion
793
+ //#region src/hooks/use-media-library.ts
794
+ function useMediaLibrary({ collection, pageSize = 12, initialSearchQuery = "", initialSelectedIds = [] }) {
795
+ const { client, schemas } = useDyrected();
796
+ const selectedIdsKey = React$1.useMemo(() => initialSelectedIds.join("|"), [initialSelectedIds]);
797
+ const controller = React$1.useMemo(() => {
798
+ return createMediaLibraryController({
799
+ client,
800
+ schemas,
801
+ collection,
802
+ pageSize,
803
+ initialSearchQuery,
804
+ initialSelectedIds
805
+ });
806
+ }, [
807
+ client,
808
+ schemas,
809
+ collection,
810
+ pageSize,
811
+ initialSearchQuery,
812
+ selectedIdsKey
813
+ ]);
814
+ const state = React$1.useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
815
+ const selectedItems = React$1.useMemo(() => state.items.filter((item) => state.selectedIds.includes(item.id)), [state.items, state.selectedIds]);
816
+ return {
817
+ ...state,
818
+ load: controller.load,
819
+ search: controller.search,
820
+ loadNextPage: controller.loadNextPage,
821
+ setSelectedIds: controller.setSelectedIds,
822
+ select: controller.select,
823
+ deselect: controller.deselect,
824
+ toggle: controller.toggle,
825
+ clearSelection: controller.clearSelection,
826
+ selectedItems
827
+ };
828
+ }
829
+ //#endregion
830
+ //#region src/hooks/use-media-url.ts
831
+ function useMediaURL({ collection, compressImages = true, maxDimension = 2048, quality = .85, onAdded, onError }) {
832
+ const { client, schemas } = useDyrected();
833
+ const [url, setUrl] = React$1.useState("");
834
+ const handlersRef = React$1.useRef({
835
+ onAdded,
836
+ onError
837
+ });
838
+ handlersRef.current = {
839
+ onAdded,
840
+ onError
841
+ };
842
+ const controller = React$1.useMemo(() => {
843
+ return createMediaURLController({
844
+ client,
845
+ schemas,
846
+ collection,
847
+ compressImages,
848
+ maxDimension,
849
+ quality,
850
+ onAdded: async (item) => {
851
+ await handlersRef.current.onAdded?.(item);
852
+ },
853
+ onError: (error) => {
854
+ handlersRef.current.onError?.(error);
855
+ }
856
+ });
857
+ }, [
858
+ client,
859
+ schemas,
860
+ collection,
861
+ compressImages,
862
+ maxDimension,
863
+ quality
864
+ ]);
865
+ const state = React$1.useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
866
+ const submit = React$1.useCallback(async () => {
867
+ const trimmed = url.trim();
868
+ if (!trimmed) return;
869
+ await controller.importURL(trimmed);
870
+ setUrl("");
871
+ }, [controller, url]);
872
+ const classifyURL = React$1.useCallback((nextUrl) => controller.classifyURL(nextUrl), [controller]);
873
+ return {
874
+ url,
875
+ setUrl,
876
+ submit,
877
+ importURL: controller.importURL,
878
+ classifyURL,
879
+ isSubmitting: state.isSubmitting,
880
+ canSubmit: !!url.trim() && !state.isSubmitting,
881
+ activeCollection: state.activeCollection
882
+ };
883
+ }
884
+ //#endregion
885
+ //#region src/hooks/use-media-upload.ts
886
+ function useMediaUpload({ collectionSlug = "media", compressImages = true, maxDimension = 2048, quality = .85, onCompletedItem, onAllCompleted, onError } = {}) {
887
+ const { client, schemas } = useDyrected();
888
+ const handlersRef = React$1.useRef({
889
+ onCompletedItem,
890
+ onAllCompleted,
891
+ onError
892
+ });
893
+ handlersRef.current = {
894
+ onCompletedItem,
895
+ onAllCompleted,
896
+ onError
897
+ };
898
+ const controller = React$1.useMemo(() => {
899
+ return createMediaUploadController({
900
+ client,
901
+ schemas,
902
+ collection: collectionSlug,
903
+ compressImages,
904
+ maxDimension,
905
+ quality,
906
+ onCompletedItem: async (item) => {
907
+ await handlersRef.current.onCompletedItem?.(item);
908
+ },
909
+ onAllCompleted: async (items) => {
910
+ await handlersRef.current.onAllCompleted?.(items);
911
+ },
912
+ onError: (error, file) => {
913
+ handlersRef.current.onError?.(error, file);
914
+ }
915
+ });
916
+ }, [
917
+ client,
918
+ schemas,
919
+ collectionSlug,
920
+ compressImages,
921
+ maxDimension,
922
+ quality
923
+ ]);
924
+ const state = React$1.useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
925
+ return {
926
+ queue: state.queue,
927
+ isUploading: state.isUploading,
928
+ activeCollection: state.activeCollection,
929
+ uploadFiles: controller.uploadFiles,
930
+ retryUpload: controller.retryUpload,
931
+ removeQueueItem: controller.removeQueueItem,
932
+ clearCompleted: controller.clearCompleted,
933
+ clearQueue: controller.clearCompleted
934
+ };
935
+ }
936
+ //#endregion
937
+ //#region src/components/forms/utils.ts
938
+ /**
939
+ * Normalises a field's `options` array to the canonical `{ label, value }` shape.
940
+ * Accepts either a shorthand string array or the full object form.
941
+ */
942
+ function normalizeOptions(options) {
943
+ if (!options) return [];
944
+ return options.map((opt) => typeof opt === "string" ? {
945
+ label: opt,
946
+ value: opt
947
+ } : opt);
948
+ }
949
+ /**
950
+ * Builds a Zod schema shape from a collection's field definitions.
951
+ * Used by the form engine to validate the edit form before submission.
952
+ *
953
+ * @param fields - Field definitions from the collection schema.
954
+ * @param isEdit - When `true`, password fields accept an empty string (no change).
955
+ * @returns A record of Zod validators keyed by field name, suitable for `z.object(shape)`.
956
+ */
957
+ function buildSchemaShape(fields, isEdit = false) {
958
+ const shape = {};
959
+ fields.forEach((field) => {
960
+ if (field.type === "join") return;
961
+ if (field.type === "row" && field.fields) {
962
+ Object.assign(shape, buildSchemaShape(field.fields, isEdit));
963
+ return;
964
+ }
965
+ const name = field.name;
966
+ let validator = z.any();
967
+ const label = field.label || name.charAt(0).toUpperCase() + name.slice(1);
968
+ const isPassword = name === "password" || field.type === "password";
969
+ if (field.type === "object" && field.fields) {
970
+ validator = z.object(buildSchemaShape(field.fields, isEdit));
971
+ if (!field.required) validator = validator.optional();
972
+ shape[name] = validator;
973
+ return;
974
+ }
975
+ if (field.type === "blocks") {
976
+ validator = z.array(z.any());
977
+ if (!field.required) validator = validator.optional();
978
+ shape[name] = validator;
979
+ return;
980
+ }
981
+ if (field.type === "array" && field.fields) {
982
+ validator = z.array(z.object(buildSchemaShape(field.fields, isEdit)));
983
+ if (!field.required) validator = validator.optional();
984
+ shape[name] = validator;
985
+ return;
986
+ }
987
+ const fieldType = field.type;
988
+ if (fieldType === "relationship" || fieldType === "image") {
989
+ const singleRelSchema = z.union([z.string(), z.object({ id: z.string() }).passthrough()]);
990
+ validator = z.union([singleRelSchema, z.array(singleRelSchema)]);
991
+ if (field.required) validator = validator.refine((val) => {
992
+ if (typeof val === "string") return val.trim().length > 0;
993
+ if (Array.isArray(val)) {
994
+ if (val.length === 0) return false;
995
+ return val.every((item) => {
996
+ if (typeof item === "string") return item.trim().length > 0;
997
+ if (item && typeof item === "object") return typeof item.id === "string" && item.id.trim().length > 0;
998
+ return false;
999
+ });
1000
+ }
1001
+ if (val && typeof val === "object") return typeof val.id === "string" && val.id.trim().length > 0;
1002
+ return false;
1003
+ }, { message: `${label} is required` });
1004
+ } else if (fieldType === "text" || fieldType === "textarea" || fieldType === "select" || fieldType === "radio" || fieldType === "richText" || fieldType === "date" || fieldType === "datetime" || fieldType === "time" || fieldType === "icon" || fieldType === "password") {
1005
+ validator = z.string();
1006
+ if (isPassword) if (!isEdit) validator = validator.min(8, "Password must be at least 8 characters");
1007
+ else validator = z.string().refine((val) => val === "" || val.length >= 8, { message: "Password must be at least 8 characters" });
1008
+ else if (field.required) validator = validator.min(1, `${label} is required`);
1009
+ } else if (field.type === "email") {
1010
+ validator = z.string().email(`${label} must be a valid email`);
1011
+ if (field.required) validator = validator.min(1, `${label} is required`);
1012
+ } else if (field.type === "url") {
1013
+ const urlObjectSchema = z.object({
1014
+ type: z.enum(["custom", "internal"]),
1015
+ url: z.string(),
1016
+ relationTo: z.string().optional(),
1017
+ value: z.string().optional(),
1018
+ label: z.string().optional()
1019
+ });
1020
+ validator = z.union([z.string(), urlObjectSchema]);
1021
+ if (field.required) validator = validator.refine((val) => {
1022
+ if (typeof val === "string") return val.trim().length > 0;
1023
+ if (val && typeof val === "object") return typeof val.url === "string" && val.url.trim().length > 0;
1024
+ return false;
1025
+ }, { message: `${label} is required` });
1026
+ } else if (field.type === "number") validator = z.coerce.number();
1027
+ else if (field.type === "boolean") validator = z.boolean();
1028
+ else if (field.type === "json") validator = z.any();
1029
+ else if (field.type === "multiSelect") {
1030
+ validator = z.array(z.string());
1031
+ if (field.required) validator = validator.min(1, `${label} requires at least one selection`);
1032
+ }
1033
+ if (!field.required && field.type !== "multiSelect") validator = validator.optional().or(z.literal(""));
1034
+ else if (!field.required && field.type === "multiSelect") validator = validator.optional();
1035
+ shape[name] = validator;
1036
+ });
1037
+ return shape;
1038
+ }
1039
+ /**
1040
+ * Builds `react-hook-form` default values from a collection's field definitions
1041
+ * and an existing document (or an empty object for new documents).
1042
+ *
1043
+ * Handles nested `object`, `array`, and `blocks` fields recursively.
1044
+ * Relationship and image fields are normalised to their IDs.
1045
+ * Password fields are always reset to `""` so they are never pre-filled.
1046
+ *
1047
+ * @param fields - Field definitions from the collection schema.
1048
+ * @param defaults - Existing document data, or `{}` for a new document.
1049
+ */
1050
+ function buildDefaultValues(fields, defaults) {
1051
+ return fields.reduce((acc, field) => {
1052
+ if (field.type === "join") {
1053
+ if (field.name && defaults[field.name] !== void 0) acc[field.name] = defaults[field.name];
1054
+ return acc;
1055
+ }
1056
+ if (field.type === "row" && field.fields) {
1057
+ Object.assign(acc, buildDefaultValues(field.fields, defaults));
1058
+ return acc;
1059
+ }
1060
+ const name = field.name;
1061
+ let defaultVal = defaults[name] ?? field.defaultValue;
1062
+ if (name === "password" || field.type === "password") defaultVal = "";
1063
+ if (field.type === "object" && field.fields) {
1064
+ acc[name] = buildDefaultValues(field.fields, defaultVal || {});
1065
+ return acc;
1066
+ }
1067
+ if (field.type === "array") {
1068
+ const arr = Array.isArray(defaultVal) ? defaultVal : [];
1069
+ if (field.fields) acc[name] = arr.map((item) => buildDefaultValues(field.fields, item || {}));
1070
+ else acc[name] = arr;
1071
+ return acc;
1072
+ }
1073
+ if (field.type === "blocks") {
1074
+ acc[name] = (Array.isArray(defaultVal) ? defaultVal : []).map((item) => {
1075
+ const block = field.blocks?.find((b) => b.slug === item.blockType);
1076
+ if (block && block.fields) {
1077
+ const merged = {
1078
+ ...item,
1079
+ ...buildDefaultValues(block.fields, item || {})
1080
+ };
1081
+ if (block.variants?.length && merged.variant == null) merged.variant = block.variants[0].slug;
1082
+ return merged;
1083
+ }
1084
+ return item;
1085
+ });
1086
+ return acc;
1087
+ }
1088
+ if (field.type === "relationship" || field.type === "image") {
1089
+ if (Array.isArray(defaultVal)) acc[name] = defaultVal.map((val) => val && typeof val === "object" && "id" in val ? val.id : val);
1090
+ else if (defaultVal && typeof defaultVal === "object" && "id" in defaultVal) acc[name] = defaultVal.id;
1091
+ else acc[name] = defaultVal ?? "";
1092
+ return acc;
1093
+ }
1094
+ if (defaultVal === void 0) if (field.type === "boolean") defaultVal = false;
1095
+ else if (field.type === "multiSelect") defaultVal = [];
1096
+ else if (field.type === "json") defaultVal = {};
1097
+ else defaultVal = "";
1098
+ acc[name] = defaultVal;
1099
+ return acc;
1100
+ }, {});
1101
+ }
1102
+ /**
1103
+ * Flattens a nested `react-hook-form` errors object into a list of
1104
+ * `{ path, message }` pairs for rendering under individual fields.
1105
+ *
1106
+ * @param errors - The `formState.errors` object from `react-hook-form`.
1107
+ * @param path - Dot-notation prefix accumulated during recursion (omit when calling externally).
1108
+ * @returns A flat array of field path / error message pairs.
1109
+ */
1110
+ function getFlatErrors(errors, path = "") {
1111
+ const result = [];
1112
+ if (!errors) return result;
1113
+ if (typeof errors === "object") {
1114
+ const asMsg = errors;
1115
+ if (typeof asMsg.message === "string") {
1116
+ result.push({
1117
+ path,
1118
+ message: asMsg.message
1119
+ });
1120
+ return result;
1121
+ }
1122
+ for (const key in errors) if (Object.prototype.hasOwnProperty.call(errors, key)) {
1123
+ if (key === "ref" || key === "type") continue;
1124
+ const nextPath = path ? `${path}.${key}` : key;
1125
+ result.push(...getFlatErrors(errors[key], nextPath));
1126
+ }
1127
+ }
1128
+ return result;
1129
+ }
1130
+ /**
1131
+ * Formats a dot-notation field path into a human-readable label.
1132
+ * Array indices are converted to 1-based "Item N" labels.
1133
+ *
1134
+ * @example
1135
+ * formatPath("address.street") // → "Address > Street"
1136
+ * formatPath("items.0.name") // → "Items > Item 1 > Name"
1137
+ */
1138
+ function formatPath(path) {
1139
+ return path.split(".").map((part) => {
1140
+ if (/^\d+$/.test(part)) return `Item ${parseInt(part, 10) + 1}`;
1141
+ return part.charAt(0).toUpperCase() + part.slice(1).replace(/([A-Z])/g, " $1");
1142
+ }).join(" > ");
1143
+ }
1144
+ /**
1145
+ * Walks the schema tree alongside a dot-notation path (e.g. "body.2.cta.url")
1146
+ * and returns the ordered PathSegments representing each drillable container
1147
+ * boundary crossed. Drillable boundaries are:
1148
+ * - `blocks` fields (always drillable)
1149
+ * - `array` or `object` fields with `admin.drillIn === true`
1150
+ * Leaf field names and raw numeric indices are consumed but not emitted.
1151
+ *
1152
+ * @param fields Top-level field schemas for the collection.
1153
+ * @param path Dot-notation path, e.g. "body.2.cta.url"
1154
+ * @param getStableId Resolves (basePath, rawIndex) → stableId from live
1155
+ * useFieldArray state. Takes the full cumulative RHF path
1156
+ * (e.g. "body.2.items"), not just the leaf field name, to
1157
+ * disambiguate identically-named sub-arrays across block types.
1158
+ * @returns Ordered PathSegment[] for the drill-in trail, or null if the path
1159
+ * cannot be resolved against the schema.
1160
+ */
1161
+ function resolveContainerPath(fields, path, getStableId) {
1162
+ const segments = path.split(".");
1163
+ const result = [];
1164
+ let currentFields = fields;
1165
+ let i = 0;
1166
+ let cumulativePath = "";
1167
+ const join = (base, next) => base ? `${base}.${next}` : next;
1168
+ const labelFor = (field, name) => field.label || name.charAt(0).toUpperCase() + name.slice(1);
1169
+ while (i < segments.length) {
1170
+ const segment = segments[i];
1171
+ if (/^\d+$/.test(segment)) {
1172
+ cumulativePath = join(cumulativePath, segment);
1173
+ i++;
1174
+ continue;
1175
+ }
1176
+ const field = currentFields.find((f) => f.name === segment);
1177
+ if (!field) return null;
1178
+ const isLast = i === segments.length - 1;
1179
+ const fieldPath = join(cumulativePath, segment);
1180
+ const isBlocks = field.type === "blocks";
1181
+ const isDrillInArray = field.type === "array" && field.admin?.drillIn === true;
1182
+ if (isBlocks || isDrillInArray) {
1183
+ const nextIndex = i + 1 < segments.length ? parseInt(segments[i + 1], 10) : NaN;
1184
+ if (isLast || isNaN(nextIndex)) break;
1185
+ const itemBasePath = join(fieldPath, String(nextIndex));
1186
+ result.push({
1187
+ fieldName: segment,
1188
+ basePath: itemBasePath,
1189
+ stableId: getStableId(fieldPath, nextIndex),
1190
+ breadcrumbLabel: labelFor(field, segment)
1191
+ });
1192
+ cumulativePath = itemBasePath;
1193
+ i += 2;
1194
+ currentFields = isBlocks ? (field.blocks ?? []).flatMap((b) => b.fields ?? []) : field.fields ?? [];
1195
+ continue;
1196
+ }
1197
+ if (field.type === "object" && field.admin?.drillIn === true) {
1198
+ if (isLast) break;
1199
+ result.push({
1200
+ fieldName: segment,
1201
+ basePath: fieldPath,
1202
+ stableId: void 0,
1203
+ breadcrumbLabel: labelFor(field, segment)
1204
+ });
1205
+ cumulativePath = fieldPath;
1206
+ i++;
1207
+ currentFields = field.fields ?? [];
1208
+ continue;
1209
+ }
1210
+ cumulativePath = fieldPath;
1211
+ if (field.fields && !isLast) currentFields = field.fields;
1212
+ i++;
1213
+ }
1214
+ return result;
1215
+ }
1216
+ //#endregion
1217
+ //#region src/controllers/form.ts
1218
+ /**
1219
+ * Normalizes a dotted field path into path segments.
1220
+ */
1221
+ function normalizeFieldPath(path) {
1222
+ return path.split(".").map((segment) => segment.trim()).filter(Boolean);
1223
+ }
1224
+ /**
1225
+ * Alias for `normalizeFieldPath` used in public field/path helpers.
1226
+ */
1227
+ function getFieldPathSegments(path) {
1228
+ return normalizeFieldPath(path);
1229
+ }
1230
+ /**
1231
+ * Joins path parts into one dotted Dyrected field path.
1232
+ */
1233
+ function joinFieldPath(...parts) {
1234
+ return parts.flatMap((part) => {
1235
+ if (part === null || part === void 0 || part === false) return [];
1236
+ return String(part).split(".").map((segment) => segment.trim()).filter(Boolean);
1237
+ }).join(".");
1238
+ }
1239
+ /**
1240
+ * Returns the parent path for a given dotted field path.
1241
+ */
1242
+ function getParentFieldPath(path) {
1243
+ return normalizeFieldPath(path).slice(0, -1).join(".");
1244
+ }
1245
+ /**
1246
+ * Reads a nested value using a dotted Dyrected field path.
1247
+ */
1248
+ function getValueAtPath(value, path) {
1249
+ if (!path) return value;
1250
+ return normalizeFieldPath(path).reduce((currentValue, segment) => {
1251
+ if (currentValue == null) return void 0;
1252
+ return currentValue[segment];
1253
+ }, value);
1254
+ }
1255
+ /**
1256
+ * Writes a nested value immutably using a dotted Dyrected field path.
1257
+ */
1258
+ function setValueAtPath(value, path, nextValue) {
1259
+ const segments = normalizeFieldPath(path);
1260
+ if (segments.length === 0) return nextValue;
1261
+ const root = Array.isArray(value) ? [...value] : { ...value ?? {} };
1262
+ let cursor = root;
1263
+ segments.forEach((segment, index) => {
1264
+ if (index === segments.length - 1) {
1265
+ cursor[segment] = nextValue;
1266
+ return;
1267
+ }
1268
+ const existing = cursor[segment];
1269
+ const nextSegment = segments[index + 1];
1270
+ const nextContainer = existing && typeof existing === "object" ? Array.isArray(existing) ? [...existing] : { ...existing } : /^\d+$/.test(nextSegment) ? [] : {};
1271
+ cursor[segment] = nextContainer;
1272
+ cursor = nextContainer;
1273
+ });
1274
+ return root;
1275
+ }
1276
+ function findFieldSchema(fields, path) {
1277
+ const segments = normalizeFieldPath(path).filter((segment) => !/^\d+$/.test(segment));
1278
+ if (segments.length === 0) return null;
1279
+ let currentFields = fields;
1280
+ let currentField = null;
1281
+ for (const segment of segments) {
1282
+ currentField = currentFields.find((field) => field.name === segment) ?? null;
1283
+ if (!currentField) return null;
1284
+ if (currentField.type === "blocks") {
1285
+ currentFields = currentField.blocks?.flatMap((block) => block.fields ?? []) ?? [];
1286
+ continue;
1287
+ }
1288
+ currentFields = currentField.fields ?? [];
1289
+ }
1290
+ return currentField;
1291
+ }
1292
+ /**
1293
+ * Creates a framework-agnostic Dyrected form controller.
1294
+ *
1295
+ * This is the low-level state contract behind the React and Vue form APIs.
1296
+ */
1297
+ function createDyrectedFormController({ collection, fields, documentId, readOnly = false, initialValues = {}, initialErrors = {}, initialDirtyFields = {}, initialTouchedFields = {}, initialIsDirty = false, initialIsSubmitting = false, initialIsValid = true, initialSubmitCount = 0, adapters }) {
1298
+ let state = {
1299
+ collection,
1300
+ fields,
1301
+ values: initialValues,
1302
+ errors: initialErrors,
1303
+ dirtyFields: initialDirtyFields,
1304
+ touchedFields: initialTouchedFields,
1305
+ isDirty: initialIsDirty,
1306
+ isSubmitting: initialIsSubmitting,
1307
+ isValid: initialIsValid,
1308
+ submitCount: initialSubmitCount,
1309
+ readOnly,
1310
+ documentId
1311
+ };
1312
+ const listeners = /* @__PURE__ */ new Set();
1313
+ let controllerAdapters = adapters;
1314
+ const emit = () => {
1315
+ listeners.forEach((listener) => listener());
1316
+ };
1317
+ const setState = (nextState) => {
1318
+ state = typeof nextState === "function" ? nextState(state) : {
1319
+ ...state,
1320
+ ...nextState
1321
+ };
1322
+ emit();
1323
+ };
1324
+ const controller = {
1325
+ getState: () => state,
1326
+ subscribe: (listener) => {
1327
+ listeners.add(listener);
1328
+ return () => listeners.delete(listener);
1329
+ },
1330
+ setAdapters: (nextAdapters) => {
1331
+ controllerAdapters = nextAdapters;
1332
+ },
1333
+ setState,
1334
+ getValue: (path) => getValueAtPath(state.values, path),
1335
+ getValues: () => state.values,
1336
+ setValue: (path, value, options) => {
1337
+ controllerAdapters?.setValue?.(path, value, options);
1338
+ state = {
1339
+ ...state,
1340
+ values: setValueAtPath(state.values, path, value),
1341
+ isDirty: options?.shouldDirty ? true : state.isDirty,
1342
+ dirtyFields: options?.shouldDirty ? {
1343
+ ...state.dirtyFields,
1344
+ [path]: true
1345
+ } : state.dirtyFields,
1346
+ touchedFields: options?.shouldTouch ? {
1347
+ ...state.touchedFields,
1348
+ [path]: true
1349
+ } : state.touchedFields
1350
+ };
1351
+ emit();
1352
+ },
1353
+ getFieldSchema: (path) => findFieldSchema(state.fields, path),
1354
+ getFieldState: (path) => {
1355
+ const error = state.errors[path];
1356
+ return {
1357
+ path,
1358
+ schema: findFieldSchema(state.fields, path),
1359
+ value: getValueAtPath(state.values, path),
1360
+ error,
1361
+ isDirty: Boolean(state.dirtyFields[path]),
1362
+ isTouched: Boolean(state.touchedFields[path]),
1363
+ invalid: Boolean(error),
1364
+ setValue: (value, options) => {
1365
+ if (state.readOnly) return;
1366
+ controller.setValue(path, value, {
1367
+ shouldDirty: true,
1368
+ shouldTouch: true,
1369
+ ...options
1370
+ });
1371
+ },
1372
+ validate: async () => controller.validate(path)
1373
+ };
1374
+ },
1375
+ reset: (values) => {
1376
+ controllerAdapters?.reset?.(values);
1377
+ state = {
1378
+ ...state,
1379
+ values: values ?? {},
1380
+ errors: {},
1381
+ dirtyFields: {},
1382
+ touchedFields: {},
1383
+ isDirty: false
1384
+ };
1385
+ emit();
1386
+ },
1387
+ validate: async (paths) => controllerAdapters?.validate?.(paths) ?? true,
1388
+ submit: async () => {
1389
+ if (!controllerAdapters?.submit) return state.values;
1390
+ return controllerAdapters.submit();
1391
+ }
1392
+ };
1393
+ return controller;
1394
+ }
1395
+ //#endregion
1396
+ //#region src/providers/dyrected-form-context.tsx
1397
+ var DyrectedFormContext = React$1.createContext(null);
1398
+ var DyrectedFieldPathContext = React$1.createContext(null);
1399
+ function DyrectedFormProvider({ controller, children }) {
1400
+ return /* @__PURE__ */ jsx(DyrectedFormContext.Provider, {
1401
+ value: controller,
1402
+ children
1403
+ });
1404
+ }
1405
+ function DyrectedFieldPathProvider({ path, children }) {
1406
+ return /* @__PURE__ */ jsx(DyrectedFieldPathContext.Provider, {
1407
+ value: path,
1408
+ children
1409
+ });
1410
+ }
1411
+ function useDyrectedFormControllerContext() {
1412
+ const controller = React$1.useContext(DyrectedFormContext);
1413
+ if (!controller) throw new Error("useDyrectedForm must be used within a DyrectedFormProvider");
1414
+ return controller;
1415
+ }
1416
+ function useDyrectedFieldPathContext() {
1417
+ return React$1.useContext(DyrectedFieldPathContext);
1418
+ }
1419
+ //#endregion
1420
+ //#region src/hooks/use-dyrected-form.ts
1421
+ function useDyrectedForm() {
1422
+ const controller = useDyrectedFormControllerContext();
1423
+ return {
1424
+ ...React$1.useSyncExternalStore(controller.subscribe, controller.getState, controller.getState),
1425
+ getValue: controller.getValue,
1426
+ getValues: controller.getValues,
1427
+ setValue: controller.setValue,
1428
+ getFieldSchema: controller.getFieldSchema,
1429
+ getFieldState: controller.getFieldState,
1430
+ reset: controller.reset,
1431
+ validate: controller.validate,
1432
+ submit: controller.submit,
1433
+ controller
1434
+ };
1435
+ }
1436
+ //#endregion
1437
+ //#region src/controllers/field.ts
1438
+ /**
1439
+ * Creates a field-scoped controller from a Dyrected form controller and a path.
1440
+ */
1441
+ function createDyrectedFieldController(formController, path) {
1442
+ let lastFormState = formController.getState();
1443
+ let lastFieldState = formController.getFieldState(path);
1444
+ return {
1445
+ getState: () => {
1446
+ const nextFormState = formController.getState();
1447
+ if (nextFormState === lastFormState) return lastFieldState;
1448
+ lastFormState = nextFormState;
1449
+ lastFieldState = formController.getFieldState(path);
1450
+ return lastFieldState;
1451
+ },
1452
+ subscribe: (listener) => formController.subscribe(listener),
1453
+ setValue: (value, options) => formController.setValue(path, value, options),
1454
+ validate: () => formController.validate(path)
1455
+ };
1456
+ }
1457
+ //#endregion
1458
+ //#region src/hooks/use-field.ts
1459
+ function useField(path) {
1460
+ const formController = useDyrectedFormControllerContext();
1461
+ const contextPath = useDyrectedFieldPathContext();
1462
+ const resolvedPath = path ?? contextPath;
1463
+ if (!resolvedPath) throw new Error("useField requires a field path or a DyrectedFieldPathProvider ancestor");
1464
+ const controller = React$1.useMemo(() => createDyrectedFieldController(formController, resolvedPath), [formController, resolvedPath]);
1465
+ const state = React$1.useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
1466
+ const pathSegments = React$1.useMemo(() => getFieldPathSegments(resolvedPath), [resolvedPath]);
1467
+ const parentPath = React$1.useMemo(() => getParentFieldPath(resolvedPath), [resolvedPath]);
1468
+ const getChildPath = React$1.useCallback((...parts) => joinFieldPath(resolvedPath, ...parts), [resolvedPath]);
1469
+ const getItemPath = React$1.useCallback((index, ...parts) => joinFieldPath(resolvedPath, index, ...parts), [resolvedPath]);
1470
+ const getChildValue = React$1.useCallback((...parts) => formController.getValue(getChildPath(...parts)), [formController, getChildPath]);
1471
+ const getChildSchema = React$1.useCallback((...parts) => formController.getFieldSchema(getChildPath(...parts)), [formController, getChildPath]);
1472
+ const getChildState = React$1.useCallback((...parts) => formController.getFieldState(getChildPath(...parts)), [formController, getChildPath]);
1473
+ const setChildValue = React$1.useCallback((parts, value, options) => {
1474
+ const normalizedParts = Array.isArray(parts) ? parts : [parts];
1475
+ formController.setValue(getChildPath(...normalizedParts), value, options);
1476
+ }, [formController, getChildPath]);
1477
+ return {
1478
+ ...state,
1479
+ pathSegments,
1480
+ parentPath,
1481
+ getChildPath,
1482
+ getItemPath,
1483
+ getChildValue,
1484
+ getChildSchema,
1485
+ getChildState,
1486
+ setChildValue,
1487
+ setValue: controller.setValue,
1488
+ validate: controller.validate,
1489
+ controller
1490
+ };
1491
+ }
1492
+ //#endregion
1493
+ //#region src/controllers/theme.ts
1494
+ /**
1495
+ * Creates a framework-agnostic admin theme controller.
1496
+ *
1497
+ * This controller is the shared theme engine used by the React and Vue public
1498
+ * APIs, and can also be used directly by other framework adapters.
1499
+ */
1500
+ function createAdminThemeController({ theme = "system", systemTheme = "light", onThemeChange } = {}) {
1501
+ let state = {
1502
+ theme,
1503
+ systemTheme,
1504
+ resolvedTheme: resolveAdminTheme(theme, systemTheme),
1505
+ themeClassName: adminThemeClassName(resolveAdminTheme(theme, systemTheme))
1506
+ };
1507
+ const listeners = /* @__PURE__ */ new Set();
1508
+ const emit = () => {
1509
+ listeners.forEach((listener) => listener());
1510
+ };
1511
+ const normalizeState = (nextState) => {
1512
+ const nextTheme = nextState.theme ?? state.theme;
1513
+ const nextSystemTheme = nextState.systemTheme ?? state.systemTheme;
1514
+ const resolvedTheme = resolveAdminTheme(nextTheme, nextSystemTheme);
1515
+ return {
1516
+ ...state,
1517
+ ...nextState,
1518
+ theme: nextTheme,
1519
+ systemTheme: nextSystemTheme,
1520
+ resolvedTheme,
1521
+ themeClassName: adminThemeClassName(resolvedTheme)
1522
+ };
1523
+ };
1524
+ return {
1525
+ getState: () => state,
1526
+ subscribe: (listener) => {
1527
+ listeners.add(listener);
1528
+ return () => listeners.delete(listener);
1529
+ },
1530
+ setTheme: (nextTheme) => {
1531
+ if (state.theme === nextTheme) return;
1532
+ state = normalizeState({ theme: nextTheme });
1533
+ emit();
1534
+ onThemeChange?.(nextTheme);
1535
+ },
1536
+ setSystemTheme: (nextSystemTheme) => {
1537
+ if (state.systemTheme === nextSystemTheme) return;
1538
+ state = normalizeState({ systemTheme: nextSystemTheme });
1539
+ emit();
1540
+ },
1541
+ setState: (nextState) => {
1542
+ const resolvedState = typeof nextState === "function" ? nextState(state) : normalizeState(nextState);
1543
+ if (Object.is(resolvedState, state)) return;
1544
+ state = resolvedState;
1545
+ emit();
1546
+ }
1547
+ };
1548
+ }
1549
+ //#endregion
1550
+ //#region src/hooks/admin-theme-provider.tsx
1551
+ function subscribeToSystemAdminTheme(onChange) {
1552
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => void 0;
1553
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
1554
+ const syncSystemTheme = () => {
1555
+ onChange(media.matches ? "dark" : "light");
1556
+ };
1557
+ syncSystemTheme();
1558
+ media.addEventListener("change", syncSystemTheme);
1559
+ return () => media.removeEventListener("change", syncSystemTheme);
1560
+ }
1561
+ function AdminThemeProvider({ children, controller }) {
1562
+ const [theme, setTheme] = usePreferences("theme", "system");
1563
+ const activeController = React$1.useRef(controller ?? createAdminThemeController({
1564
+ theme,
1565
+ systemTheme: getSystemAdminTheme(),
1566
+ onThemeChange: setTheme
1567
+ })).current;
1568
+ React$1.useEffect(() => {
1569
+ activeController.setState((currentState) => {
1570
+ if (currentState.theme === theme) return currentState;
1571
+ return {
1572
+ ...currentState,
1573
+ theme
1574
+ };
1575
+ });
1576
+ }, [activeController, theme]);
1577
+ React$1.useEffect(() => {
1578
+ return subscribeToSystemAdminTheme((systemTheme) => {
1579
+ activeController.setSystemTheme(systemTheme);
1580
+ });
1581
+ }, [activeController]);
1582
+ return /* @__PURE__ */ jsx(AdminThemeContext.Provider, {
1583
+ value: activeController,
1584
+ children
1585
+ });
1586
+ }
1587
+ function AdminThemedRoot({ children }) {
1588
+ const { resolvedTheme, themeClassName } = useAdminTheme();
1589
+ return /* @__PURE__ */ jsx("div", {
1590
+ className: `${themeClassName} dy-h-full`,
1591
+ "data-theme": resolvedTheme,
1592
+ children
1593
+ });
1594
+ }
1595
+ //#endregion
1596
+ //#region src/hooks/use-add-media-from-url.ts
1597
+ /**
1598
+ * @deprecated Use `useMediaURL` for new code. This alias exists for backwards compatibility.
1599
+ */
1600
+ function useAddMediaFromUrl(options) {
1601
+ return useMediaURL(options);
1602
+ }
1603
+ //#endregion
1604
+ export { buildExternalMediaPayload as A, isStorageNotConfiguredError as B, useMediaUpload as C, createMediaURLController as D, createMediaLibraryController as E, isEmbeddableVideoUrl as F, resolveAdminTheme as G, useAdminTheme as H, usePreferences as I, getMediaUrl as J, cn as K, formatMediaErrorMessage as L, getMediaPreviewUrl as M, getVideoEmbedUrl as N, createMediaUploadController as O, isDirectImageUrl as P, getMediaSourceInfo as R, resolveContainerPath as S, useMediaLibrary as T, adminThemeClassName as U, resolveActiveMediaCollection as V, getSystemAdminTheme as W, DyrectedContext as X, getSiteUrl as Y, useDyrected as Z, buildDefaultValues as _, useField as a, getFlatErrors as b, DyrectedFieldPathProvider as c, getFieldPathSegments as d, getParentFieldPath as f, setValueAtPath as g, normalizeFieldPath as h, createAdminThemeController as i, filenameFromUrl as j, compressImage as k, DyrectedFormProvider as l, joinFieldPath as m, AdminThemeProvider as n, createDyrectedFieldController as o, getValueAtPath as p, getDisplayFilename as q, AdminThemedRoot as r, useDyrectedForm as s, useAddMediaFromUrl as t, createDyrectedFormController as u, buildSchemaShape as v, useMediaURL as w, normalizeOptions as x, formatPath as y, isExternalMedia as z };