@dyrected/admin 2.6.0 → 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.
- package/dist/admin.css +221 -32
- package/dist/components/forms/field-renderer.d.ts +3 -9
- package/dist/components/forms/fields/date-picker.d.ts +4 -1
- package/dist/components/forms/fields/format-aware-inputs.test.d.ts +1 -0
- package/dist/components/forms/fields/text-field.d.ts +1 -1
- package/dist/components/forms/fields/url-field.d.ts +1 -1
- package/dist/components/forms/form-engine.d.ts +13 -2
- package/dist/components/media/__tests__/media-library-dialog.test.d.ts +1 -0
- package/dist/components/media/storage-notice.d.ts +7 -0
- package/dist/components/workflow/WorkflowPanel.d.ts +8 -1
- package/dist/components/workflow/draft-live-compare-sheet.d.ts +6 -0
- package/dist/components/workflow/workflow-transition-controls.d.ts +60 -0
- package/dist/controllers/__tests__/field.test.d.ts +1 -0
- package/dist/controllers/__tests__/form.test.d.ts +1 -0
- package/dist/controllers/__tests__/media.test.d.ts +1 -0
- package/dist/controllers/__tests__/theme.test.d.ts +1 -0
- package/dist/controllers/field.d.ts +13 -0
- package/dist/controllers/form.d.ts +102 -0
- package/dist/controllers/media.d.ts +119 -0
- package/dist/controllers/theme.d.ts +28 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +85 -1
- package/dist/hooks/__tests__/use-add-media-from-url.test.d.ts +1 -0
- package/dist/hooks/__tests__/use-field.test.d.ts +1 -0
- package/dist/hooks/admin-theme-context.d.ts +2 -8
- package/dist/hooks/admin-theme-provider.d.ts +3 -1
- package/dist/hooks/admin-theme.d.ts +1 -0
- package/dist/hooks/use-add-media-from-url.d.ts +8 -17
- package/dist/hooks/use-admin-theme.d.ts +2 -1
- package/dist/hooks/use-dyrected-form.d.ts +2 -0
- package/dist/hooks/use-field.d.ts +2 -0
- package/dist/hooks/use-media-library.d.ts +26 -0
- package/dist/hooks/use-media-upload.d.ts +20 -0
- package/dist/hooks/use-media-url.d.ts +20 -0
- package/dist/index.d.ts +30 -3
- package/dist/index.mjs +2878 -1827
- package/dist/lib/__tests__/external-media.test.d.ts +1 -0
- package/dist/lib/__tests__/media-utils.test.d.ts +1 -0
- package/dist/lib/compress-image.d.ts +9 -0
- package/dist/lib/draft-live-compare.d.ts +38 -0
- package/dist/lib/draft-live-compare.test.d.ts +1 -0
- package/dist/lib/external-media.d.ts +9 -4
- package/dist/lib/media-utils.d.ts +26 -0
- package/dist/lib/workflow-autosave.d.ts +18 -0
- package/dist/lib/workflow-autosave.test.d.ts +1 -0
- package/dist/lib/workflow-ui.d.ts +22 -0
- package/dist/lib/workflow-ui.test.d.ts +1 -0
- package/dist/providers/dyrected-form-context.d.ts +14 -0
- package/dist/public/contracts.d.ts +230 -0
- package/dist/public/index.d.ts +26 -0
- package/dist/public/index.js +2 -0
- package/dist/public.d.ts +2 -0
- package/dist/types/admin-components.d.ts +17 -2
- package/dist/use-add-media-from-url-DHGAAVLY.js +1604 -0
- package/package.json +10 -4
|
@@ -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 };
|