@sudajs/theme-engine 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/agent.d.ts +4 -4
  2. package/dist/assets.d.ts +34 -3
  3. package/dist/assets.d.ts.map +1 -1
  4. package/dist/assets.js +24 -9
  5. package/dist/assets.js.map +1 -1
  6. package/dist/components/metadata.d.ts +7 -0
  7. package/dist/components/metadata.d.ts.map +1 -1
  8. package/dist/components/metadata.js +8 -3
  9. package/dist/components/metadata.js.map +1 -1
  10. package/dist/editor/context.d.ts +33 -0
  11. package/dist/editor/context.d.ts.map +1 -1
  12. package/dist/editor/context.js.map +1 -1
  13. package/dist/editor/index.d.ts +1 -1
  14. package/dist/editor/index.d.ts.map +1 -1
  15. package/dist/editor/index.js.map +1 -1
  16. package/dist/editor/use-theme-runtime.d.ts +4 -0
  17. package/dist/editor/use-theme-runtime.d.ts.map +1 -1
  18. package/dist/editor/use-theme-runtime.js +2 -2
  19. package/dist/editor/use-theme-runtime.js.map +1 -1
  20. package/dist/fields/basic-fields.d.ts +0 -1
  21. package/dist/fields/basic-fields.d.ts.map +1 -1
  22. package/dist/fields/basic-fields.js +0 -7
  23. package/dist/fields/basic-fields.js.map +1 -1
  24. package/dist/fields/index.d.ts +1 -0
  25. package/dist/fields/index.d.ts.map +1 -1
  26. package/dist/fields/index.js +1 -0
  27. package/dist/fields/index.js.map +1 -1
  28. package/dist/fields/suda-fields.d.ts.map +1 -1
  29. package/dist/fields/suda-fields.js +2 -4
  30. package/dist/fields/suda-fields.js.map +1 -1
  31. package/dist/fields/url-field.d.ts +3 -0
  32. package/dist/fields/url-field.d.ts.map +1 -0
  33. package/dist/fields/url-field.js +742 -0
  34. package/dist/fields/url-field.js.map +1 -0
  35. package/dist/render/render-string.d.ts +4 -0
  36. package/dist/render/render-string.d.ts.map +1 -0
  37. package/dist/render/render-string.js +23 -0
  38. package/dist/render/render-string.js.map +1 -0
  39. package/dist/render/render.client.d.ts +11 -0
  40. package/dist/render/render.client.d.ts.map +1 -0
  41. package/dist/render/render.client.js +14 -0
  42. package/dist/render/render.client.js.map +1 -0
  43. package/dist/server.d.ts +2 -0
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +1 -0
  46. package/dist/server.js.map +1 -1
  47. package/package.json +9 -3
@@ -0,0 +1,742 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { z } from "zod";
5
+ import { useThemeEditorContext, } from "../editor/context.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Constants & helpers (1:1 ported from legacy property_form.tsx)
8
+ // ---------------------------------------------------------------------------
9
+ const EXTERNAL_URL_TYPE = "external";
10
+ const URL_RESOURCE_TYPE_ALIASES = {
11
+ posts: "post",
12
+ pages: "page",
13
+ tags: "tag",
14
+ };
15
+ const URL_PICKER_LIST_PATHS = {
16
+ post: "/posts",
17
+ };
18
+ const URL_PICKER_FILTER_SEARCH_TYPES = {
19
+ post: { by_tag: "tag" },
20
+ };
21
+ function isListStrategyResourceType(type) {
22
+ return Object.prototype.hasOwnProperty.call(URL_PICKER_LIST_PATHS, type);
23
+ }
24
+ const LIST_VIRTUAL_SUFFIX = "_list";
25
+ function isVirtualListType(virtualType) {
26
+ return virtualType.endsWith(LIST_VIRTUAL_SUFFIX);
27
+ }
28
+ function realTypeFromVirtual(virtualType) {
29
+ const real = isVirtualListType(virtualType)
30
+ ? virtualType.slice(0, -LIST_VIRTUAL_SUFFIX.length)
31
+ : virtualType;
32
+ return real;
33
+ }
34
+ function buildListUrl(type, params) {
35
+ const basePath = URL_PICKER_LIST_PATHS[type];
36
+ if (!params || Object.keys(params).length === 0) {
37
+ return basePath;
38
+ }
39
+ const searchParams = new URLSearchParams();
40
+ for (const [key, value] of Object.entries(params)) {
41
+ const normalized = value.trim();
42
+ if (normalized !== "") {
43
+ searchParams.set(key, normalized);
44
+ }
45
+ }
46
+ const queryString = searchParams.toString();
47
+ return queryString === "" ? basePath : `${basePath}?${queryString}`;
48
+ }
49
+ const RESOURCE_SEARCH_PAGE_SIZE = 20;
50
+ const RESOURCE_SEARCH_DEBOUNCE_MS = 250;
51
+ const RESOURCE_SCROLL_LOAD_THRESHOLD_PX = 32;
52
+ const externalUrlInputSchema = z.string().url();
53
+ function isExternalUrlValue(value) {
54
+ const trimmed = value.trim();
55
+ if (trimmed === "") {
56
+ return false;
57
+ }
58
+ return /^(https?:\/\/|\/\/|mailto:|tel:)/i.test(trimmed);
59
+ }
60
+ function isValidExternalUrlInput(value) {
61
+ const input = value.trim();
62
+ if (input === "") {
63
+ return true;
64
+ }
65
+ return externalUrlInputSchema.safeParse(input).success;
66
+ }
67
+ function titleCase(value) {
68
+ return value.charAt(0).toUpperCase() + value.slice(1).replace(/_/g, " ");
69
+ }
70
+ const RESOURCE_TYPE_LABELS_EN = {
71
+ post: "Post",
72
+ post_list: "Post List",
73
+ page: "Page",
74
+ tag: "Tag",
75
+ external: "External URL",
76
+ };
77
+ const RESOURCE_TYPE_LABELS_ZH = {
78
+ post: "文章",
79
+ post_list: "文章列表",
80
+ page: "页面",
81
+ tag: "标签",
82
+ external: "外部链接",
83
+ };
84
+ const STRATEGY_LABELS_EN = {
85
+ latest: "Latest",
86
+ featured: "Featured",
87
+ by_tag: "By tag",
88
+ manual: "Select",
89
+ };
90
+ const STRATEGY_LABELS_ZH = {
91
+ latest: "最新",
92
+ featured: "精选",
93
+ by_tag: "按标签",
94
+ manual: "选择",
95
+ };
96
+ const COMMON_LABELS_EN = {
97
+ search: "Search",
98
+ searching: "Searching…",
99
+ noResults: "No results",
100
+ searchFailed: "Search failed",
101
+ retry: "Retry",
102
+ confirm: "Confirm",
103
+ invalidUrl: "Please enter a valid URL",
104
+ select: "Select",
105
+ externalUrl: "External URL",
106
+ manual: "Manual",
107
+ loadMore: "Load more",
108
+ };
109
+ const COMMON_LABELS_ZH = {
110
+ search: "搜索",
111
+ searching: "搜索中…",
112
+ noResults: "没有结果",
113
+ searchFailed: "搜索失败",
114
+ retry: "重试",
115
+ confirm: "确认",
116
+ invalidUrl: "请输入有效的 URL",
117
+ select: "选择",
118
+ externalUrl: "外部链接",
119
+ manual: "手动",
120
+ loadMore: "加载更多",
121
+ };
122
+ function isZhLocale(locale) {
123
+ if (!locale) {
124
+ return false;
125
+ }
126
+ return locale.toLowerCase().startsWith("zh");
127
+ }
128
+ function commonLabels(locale) {
129
+ return isZhLocale(locale) ? COMMON_LABELS_ZH : COMMON_LABELS_EN;
130
+ }
131
+ function resourceTypeLabel(type, locale) {
132
+ const map = isZhLocale(locale) ? RESOURCE_TYPE_LABELS_ZH : RESOURCE_TYPE_LABELS_EN;
133
+ return map[type] ?? titleCase(type);
134
+ }
135
+ function strategyLabel(configs, resourceType, strategy, locale) {
136
+ const overrides = configs?.content?.strategyLabels?.[resourceType];
137
+ if (overrides && typeof overrides[strategy] === "string") {
138
+ return overrides[strategy];
139
+ }
140
+ const map = isZhLocale(locale) ? STRATEGY_LABELS_ZH : STRATEGY_LABELS_EN;
141
+ return map[strategy];
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Inline styles (matches the rest of theme-engine fields)
145
+ // ---------------------------------------------------------------------------
146
+ const triggerStyle = {
147
+ display: "flex",
148
+ alignItems: "center",
149
+ justifyContent: "space-between",
150
+ gap: 6,
151
+ width: "100%",
152
+ minHeight: 32,
153
+ padding: "4px 10px",
154
+ borderRadius: 6,
155
+ border: "1px solid var(--puck-color-grey-09, #dcdcdc)",
156
+ background: "var(--puck-color-white, #fff)",
157
+ cursor: "pointer",
158
+ fontSize: 12,
159
+ textAlign: "left",
160
+ overflow: "hidden",
161
+ };
162
+ const rootPanelStyle = {
163
+ position: "fixed",
164
+ zIndex: 10000,
165
+ borderRadius: 8,
166
+ border: "1px solid var(--puck-color-grey-08, #c8c8c8)",
167
+ background: "#fff",
168
+ boxShadow: "0 12px 32px rgba(15, 23, 42, 0.18)",
169
+ overflow: "visible",
170
+ fontSize: 12,
171
+ width: 280,
172
+ padding: 8,
173
+ };
174
+ const subPanelStyle = {
175
+ ...rootPanelStyle,
176
+ width: 280,
177
+ minHeight: 128,
178
+ };
179
+ const itemStyleBase = {
180
+ display: "flex",
181
+ alignItems: "center",
182
+ justifyContent: "space-between",
183
+ gap: 6,
184
+ minHeight: 28,
185
+ padding: "4px 8px",
186
+ borderRadius: 4,
187
+ border: "none",
188
+ background: "transparent",
189
+ cursor: "pointer",
190
+ textAlign: "left",
191
+ fontSize: 12,
192
+ color: "inherit",
193
+ width: "100%",
194
+ };
195
+ const itemActiveStyle = {
196
+ ...itemStyleBase,
197
+ background: "var(--puck-color-grey-11, #f5f5f5)",
198
+ color: "inherit",
199
+ };
200
+ const inputStyle = {
201
+ width: "100%",
202
+ minHeight: 30,
203
+ padding: "0 8px",
204
+ borderRadius: 6,
205
+ border: "1px solid var(--puck-color-grey-09, #dcdcdc)",
206
+ background: "#fff",
207
+ fontSize: 12,
208
+ };
209
+ const helperTextStyle = {
210
+ color: "#667085",
211
+ fontSize: 11,
212
+ padding: "2px 4px",
213
+ };
214
+ const buttonPrimaryStyle = {
215
+ ...itemStyleBase,
216
+ justifyContent: "center",
217
+ background: "var(--puck-color-azure-05, #1f6feb)",
218
+ color: "#fff",
219
+ fontWeight: 600,
220
+ cursor: "pointer",
221
+ };
222
+ // ---------------------------------------------------------------------------
223
+ // Component
224
+ // ---------------------------------------------------------------------------
225
+ function UrlPicker({ value, onChange, }) {
226
+ const { urlResolvableTypes, queryStrategyConfigs, editorPages = [], searchResources, editorLocale, } = useThemeEditorContext();
227
+ const labels = useMemo(() => commonLabels(editorLocale), [editorLocale]);
228
+ const resourceTypes = useMemo(() => {
229
+ // Top-level categories shown in the picker. Defaults match the legacy
230
+ // behavior (post / page); themes / hosts can override via
231
+ // `urlResolvableTypes` to restrict the list further.
232
+ const DEFAULT_TOP_LEVEL = ["post", "page"];
233
+ const PREFERRED_ORDER = ["post", "page", "tag"];
234
+ if (!urlResolvableTypes) {
235
+ return DEFAULT_TOP_LEVEL.slice();
236
+ }
237
+ const types = Object.keys(urlResolvableTypes);
238
+ const availableTypes = new Set(types);
239
+ const filtered = types.filter((type) => {
240
+ const canonicalType = URL_RESOURCE_TYPE_ALIASES[type];
241
+ return canonicalType === undefined || !availableTypes.has(canonicalType);
242
+ });
243
+ const topLevelTypes = filtered.filter((type) => type !== "tag");
244
+ const ordered = (topLevelTypes.length > 0 ? topLevelTypes : filtered).sort((a, b) => {
245
+ const aIndex = PREFERRED_ORDER.indexOf(a);
246
+ const bIndex = PREFERRED_ORDER.indexOf(b);
247
+ return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
248
+ });
249
+ return ordered.length > 0 ? ordered : DEFAULT_TOP_LEVEL.slice();
250
+ }, [urlResolvableTypes]);
251
+ const virtualResourceTypes = useMemo(() => {
252
+ return resourceTypes.flatMap((type) => isListStrategyResourceType(type) ? [`${type}${LIST_VIRTUAL_SUFFIX}`, type] : [type]);
253
+ }, [resourceTypes]);
254
+ const currentValue = value;
255
+ const triggerRef = useRef(null);
256
+ const panelRef = useRef(null);
257
+ const subPanelRef = useRef(null);
258
+ const [isOpen, setOpen] = useState(false);
259
+ const [panelPos, setPanelPos] = useState(null);
260
+ const ROOT_PANEL_WIDTH = 280;
261
+ const SUB_PANEL_WIDTH = 280;
262
+ const PANEL_OFFSET = 8;
263
+ const recomputePanelPos = useCallback(() => {
264
+ const trigger = triggerRef.current;
265
+ if (!trigger)
266
+ return;
267
+ const rect = trigger.getBoundingClientRect();
268
+ const viewportWidth = typeof window !== "undefined" ? window.innerWidth : rect.right;
269
+ const rootLeftCandidate = rect.left - ROOT_PANEL_WIDTH - PANEL_OFFSET;
270
+ const rootLeft = rootLeftCandidate > 8 ? rootLeftCandidate : Math.max(8, viewportWidth - ROOT_PANEL_WIDTH - 8);
271
+ const subRightLeft = rootLeft + ROOT_PANEL_WIDTH + PANEL_OFFSET;
272
+ const subLeft = subRightLeft + SUB_PANEL_WIDTH <= viewportWidth - 8
273
+ ? subRightLeft
274
+ : Math.max(8, rootLeft - SUB_PANEL_WIDTH - PANEL_OFFSET);
275
+ setPanelPos({
276
+ rootTop: rect.bottom + 4,
277
+ rootLeft,
278
+ subTop: rect.bottom + 4,
279
+ subLeft,
280
+ });
281
+ }, []);
282
+ useEffect(() => {
283
+ if (!isOpen) {
284
+ return;
285
+ }
286
+ recomputePanelPos();
287
+ const handleReflow = () => recomputePanelPos();
288
+ window.addEventListener("resize", handleReflow);
289
+ window.addEventListener("scroll", handleReflow, true);
290
+ return () => {
291
+ window.removeEventListener("resize", handleReflow);
292
+ window.removeEventListener("scroll", handleReflow, true);
293
+ };
294
+ }, [isOpen, recomputePanelPos]);
295
+ const initialVirtualType = virtualResourceTypes[0] ?? EXTERNAL_URL_TYPE;
296
+ const [activeVirtualType, setActiveVirtualType] = useState(initialVirtualType);
297
+ const activeType = realTypeFromVirtual(activeVirtualType);
298
+ const [activeStrategy, setActiveStrategy] = useState(isVirtualListType(activeVirtualType) && isListStrategyResourceType(activeType) ? "latest" : "manual");
299
+ const [query, setQuery] = useState("");
300
+ const [options, setOptions] = useState([]);
301
+ const [loading, setLoading] = useState(false);
302
+ const [searchFailed, setSearchFailed] = useState(false);
303
+ const [hasSearched, setHasSearched] = useState(false);
304
+ const [hasMoreOptions, setHasMoreOptions] = useState(false);
305
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
306
+ const [loadMoreFailed, setLoadMoreFailed] = useState(false);
307
+ const [currentPage, setCurrentPage] = useState(1);
308
+ const [filterQuery, setFilterQuery] = useState("");
309
+ const [filterOptions, setFilterOptions] = useState([]);
310
+ const [filterLoading, setFilterLoading] = useState(false);
311
+ const [filterSearchFailed, setFilterSearchFailed] = useState(false);
312
+ const [filterHasSearched, setFilterHasSearched] = useState(false);
313
+ const [externalInput, setExternalInput] = useState("");
314
+ const [selectedLabel, setSelectedLabel] = useState("");
315
+ const isActiveExternal = activeVirtualType === EXTERNAL_URL_TYPE;
316
+ const supportsListStrategies = isVirtualListType(activeVirtualType) && isListStrategyResourceType(activeType);
317
+ const isManualSearch = !isActiveExternal && (!supportsListStrategies || activeStrategy === "manual");
318
+ const isFilterSearch = supportsListStrategies && activeStrategy === "by_tag";
319
+ // Reset internal state when re-opening.
320
+ useEffect(() => {
321
+ if (!isOpen) {
322
+ return;
323
+ }
324
+ const defaultVirtualType = virtualResourceTypes[0] ?? EXTERNAL_URL_TYPE;
325
+ const defaultRealType = realTypeFromVirtual(defaultVirtualType);
326
+ setActiveVirtualType(defaultVirtualType);
327
+ setActiveStrategy(isVirtualListType(defaultVirtualType) && isListStrategyResourceType(defaultRealType)
328
+ ? "latest"
329
+ : "manual");
330
+ setQuery("");
331
+ setFilterQuery("");
332
+ setExternalInput(isExternalUrlValue(currentValue) ? currentValue : "");
333
+ }, [isOpen, virtualResourceTypes, currentValue]);
334
+ // Click-outside dismissal.
335
+ useEffect(() => {
336
+ if (!isOpen) {
337
+ return;
338
+ }
339
+ const handler = (event) => {
340
+ const target = event.target;
341
+ if (panelRef.current &&
342
+ target &&
343
+ !panelRef.current.contains(target) &&
344
+ (!subPanelRef.current || !subPanelRef.current.contains(target)) &&
345
+ triggerRef.current &&
346
+ !triggerRef.current.contains(target)) {
347
+ setOpen(false);
348
+ }
349
+ };
350
+ document.addEventListener("mousedown", handler);
351
+ return () => document.removeEventListener("mousedown", handler);
352
+ }, [isOpen]);
353
+ // Resolve the human-readable label for the currently saved value.
354
+ useEffect(() => {
355
+ if (!currentValue) {
356
+ setSelectedLabel("");
357
+ return;
358
+ }
359
+ let isCancelled = false;
360
+ const resolveLabel = async () => {
361
+ // 1) match against list-strategy URLs first
362
+ for (const type of resourceTypes) {
363
+ if (isCancelled)
364
+ return;
365
+ if (!isListStrategyResourceType(type))
366
+ continue;
367
+ const basePath = URL_PICKER_LIST_PATHS[type];
368
+ if (currentValue === basePath || currentValue.startsWith(`${basePath}?`)) {
369
+ const parsed = new URL(currentValue, "https://local.test");
370
+ const featured = parsed.searchParams.get("featured");
371
+ const tag = parsed.searchParams.get("tag");
372
+ if (featured === "1") {
373
+ setSelectedLabel(strategyLabel(queryStrategyConfigs, type, "featured", editorLocale));
374
+ return;
375
+ }
376
+ if (tag && tag.trim() !== "") {
377
+ setSelectedLabel(`${strategyLabel(queryStrategyConfigs, type, "by_tag", editorLocale)} · ${tag}`);
378
+ return;
379
+ }
380
+ setSelectedLabel(strategyLabel(queryStrategyConfigs, type, "latest", editorLocale));
381
+ return;
382
+ }
383
+ }
384
+ // 2) try editor pages
385
+ const matchedPage = editorPages.find((p) => {
386
+ const url = p.handle === "index" ? "/" : `/${p.handle}`;
387
+ return url === currentValue || p.handle === currentValue;
388
+ });
389
+ if (matchedPage) {
390
+ setSelectedLabel(matchedPage.label);
391
+ return;
392
+ }
393
+ // 3) ask searchResources to find a label for non-list types
394
+ if (!searchResources)
395
+ return;
396
+ for (const type of resourceTypes) {
397
+ if (isCancelled)
398
+ return;
399
+ if (type === "page")
400
+ continue;
401
+ try {
402
+ const result = await searchResources({
403
+ type: type,
404
+ query: "",
405
+ page: 1,
406
+ perPage: RESOURCE_SEARCH_PAGE_SIZE,
407
+ });
408
+ const match = result.options.find((option) => (option.url ?? option.value) === currentValue);
409
+ if (match) {
410
+ setSelectedLabel(match.label);
411
+ return;
412
+ }
413
+ }
414
+ catch {
415
+ // continue to next type
416
+ }
417
+ }
418
+ };
419
+ void resolveLabel();
420
+ return () => {
421
+ isCancelled = true;
422
+ };
423
+ }, [currentValue, resourceTypes, editorPages, queryStrategyConfigs, searchResources, editorLocale]);
424
+ const displayLabel = useMemo(() => {
425
+ if (!currentValue) {
426
+ return labels.select;
427
+ }
428
+ return selectedLabel || currentValue;
429
+ }, [currentValue, labels.select, selectedLabel]);
430
+ const selectedStrategyForType = useCallback((type) => {
431
+ if (!isListStrategyResourceType(type) || currentValue === "")
432
+ return null;
433
+ const basePath = URL_PICKER_LIST_PATHS[type];
434
+ if (currentValue === basePath)
435
+ return "latest";
436
+ if (currentValue.startsWith(`${basePath}?`)) {
437
+ const parsed = new URL(currentValue, "https://local.test");
438
+ const featured = parsed.searchParams.get("featured");
439
+ const tag = parsed.searchParams.get("tag");
440
+ if (featured === "1")
441
+ return "featured";
442
+ if (tag && tag.trim() !== "")
443
+ return "by_tag";
444
+ return "latest";
445
+ }
446
+ if (currentValue.startsWith(`${basePath}/`))
447
+ return "manual";
448
+ return null;
449
+ }, [currentValue]);
450
+ // -- Manual / page list search effect --------------------------------
451
+ useEffect(() => {
452
+ if (!isOpen)
453
+ return;
454
+ if (activeType === "page") {
455
+ const pageOptions = editorPages
456
+ .filter((p) => !p.disabled && (p.group === "home" || p.group === "static"))
457
+ .map((p) => ({
458
+ value: p.handle,
459
+ label: p.label,
460
+ url: p.handle === "index" ? "/" : `/${p.handle}`,
461
+ }));
462
+ const filtered = query.trim() === ""
463
+ ? pageOptions
464
+ : pageOptions.filter((opt) => opt.label.toLowerCase().includes(query.trim().toLowerCase()));
465
+ setOptions(filtered);
466
+ setCurrentPage(1);
467
+ setHasMoreOptions(false);
468
+ setHasSearched(true);
469
+ setLoading(false);
470
+ setSearchFailed(false);
471
+ return;
472
+ }
473
+ if (!isManualSearch || !searchResources) {
474
+ setOptions([]);
475
+ setLoading(false);
476
+ setHasSearched(false);
477
+ setHasMoreOptions(false);
478
+ setCurrentPage(1);
479
+ setSearchFailed(false);
480
+ return;
481
+ }
482
+ let isCancelled = false;
483
+ setLoading(true);
484
+ setHasSearched(false);
485
+ setSearchFailed(false);
486
+ setLoadMoreFailed(false);
487
+ const timer = window.setTimeout(() => {
488
+ void (async () => {
489
+ try {
490
+ const result = await searchResources({
491
+ type: activeType,
492
+ query: query.trim(),
493
+ page: 1,
494
+ perPage: RESOURCE_SEARCH_PAGE_SIZE,
495
+ });
496
+ if (isCancelled)
497
+ return;
498
+ setOptions(result.options);
499
+ setCurrentPage(result.page ?? 1);
500
+ setHasMoreOptions(Boolean(result.hasMore));
501
+ setHasSearched(true);
502
+ }
503
+ catch {
504
+ if (!isCancelled) {
505
+ setOptions([]);
506
+ setCurrentPage(1);
507
+ setHasMoreOptions(false);
508
+ setSearchFailed(true);
509
+ setHasSearched(true);
510
+ }
511
+ }
512
+ finally {
513
+ if (!isCancelled)
514
+ setLoading(false);
515
+ }
516
+ })();
517
+ }, RESOURCE_SEARCH_DEBOUNCE_MS);
518
+ return () => {
519
+ isCancelled = true;
520
+ window.clearTimeout(timer);
521
+ };
522
+ }, [isOpen, activeType, isManualSearch, query, searchResources, editorPages]);
523
+ // -- Filter (by_tag) search effect -----------------------------------
524
+ useEffect(() => {
525
+ if (!isOpen)
526
+ return;
527
+ if (!isFilterSearch || !searchResources || !isListStrategyResourceType(activeType)) {
528
+ setFilterOptions([]);
529
+ setFilterLoading(false);
530
+ setFilterSearchFailed(false);
531
+ setFilterHasSearched(false);
532
+ return;
533
+ }
534
+ const searchType = URL_PICKER_FILTER_SEARCH_TYPES[activeType].by_tag;
535
+ let isCancelled = false;
536
+ setFilterLoading(true);
537
+ setFilterSearchFailed(false);
538
+ setFilterHasSearched(false);
539
+ const timer = window.setTimeout(() => {
540
+ void (async () => {
541
+ try {
542
+ const result = await searchResources({
543
+ type: searchType,
544
+ query: filterQuery.trim(),
545
+ page: 1,
546
+ perPage: RESOURCE_SEARCH_PAGE_SIZE,
547
+ });
548
+ if (isCancelled)
549
+ return;
550
+ setFilterOptions(result.options);
551
+ setFilterHasSearched(true);
552
+ }
553
+ catch {
554
+ if (!isCancelled) {
555
+ setFilterOptions([]);
556
+ setFilterSearchFailed(true);
557
+ setFilterHasSearched(true);
558
+ }
559
+ }
560
+ finally {
561
+ if (!isCancelled)
562
+ setFilterLoading(false);
563
+ }
564
+ })();
565
+ }, RESOURCE_SEARCH_DEBOUNCE_MS);
566
+ return () => {
567
+ isCancelled = true;
568
+ window.clearTimeout(timer);
569
+ };
570
+ }, [isOpen, activeType, activeStrategy, filterQuery, isFilterSearch, searchResources]);
571
+ const loadMoreOptions = useCallback(async () => {
572
+ if (!searchResources || loading || isLoadingMore || !hasMoreOptions || !isManualSearch)
573
+ return;
574
+ const nextPage = currentPage + 1;
575
+ setIsLoadingMore(true);
576
+ setLoadMoreFailed(false);
577
+ try {
578
+ const result = await searchResources({
579
+ type: activeType,
580
+ query: query.trim(),
581
+ page: nextPage,
582
+ perPage: RESOURCE_SEARCH_PAGE_SIZE,
583
+ });
584
+ setOptions((previous) => {
585
+ const existingValues = new Set(previous.map((option) => option.value));
586
+ return [...previous, ...result.options.filter((option) => !existingValues.has(option.value))];
587
+ });
588
+ setCurrentPage(result.page ?? nextPage);
589
+ setHasMoreOptions(Boolean(result.hasMore));
590
+ }
591
+ catch {
592
+ setLoadMoreFailed(true);
593
+ }
594
+ finally {
595
+ setIsLoadingMore(false);
596
+ }
597
+ }, [activeType, currentPage, hasMoreOptions, isLoadingMore, isManualSearch, loading, query, searchResources]);
598
+ const handleOptionsScroll = (event) => {
599
+ if (!hasMoreOptions || loading || isLoadingMore || loadMoreFailed || !isManualSearch)
600
+ return;
601
+ const target = event.currentTarget;
602
+ const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
603
+ if (distanceToBottom <= RESOURCE_SCROLL_LOAD_THRESHOLD_PX) {
604
+ void loadMoreOptions();
605
+ }
606
+ };
607
+ const handleTypeTabChange = (virtualType) => {
608
+ const realType = realTypeFromVirtual(virtualType);
609
+ const isListMode = isVirtualListType(virtualType) && isListStrategyResourceType(realType);
610
+ setActiveVirtualType(virtualType);
611
+ setActiveStrategy(isListMode ? "latest" : "manual");
612
+ setQuery("");
613
+ setOptions([]);
614
+ setFilterQuery("");
615
+ setFilterOptions([]);
616
+ setHasSearched(false);
617
+ setFilterHasSearched(false);
618
+ setSearchFailed(false);
619
+ setFilterSearchFailed(false);
620
+ };
621
+ const handleSelectResource = (option) => {
622
+ const url = option.url ?? option.value;
623
+ setSelectedLabel(option.label);
624
+ onChange(url);
625
+ setOpen(false);
626
+ };
627
+ const handlePickListStrategy = (type, strategy) => {
628
+ if (strategy === "latest") {
629
+ setSelectedLabel(strategyLabel(queryStrategyConfigs, type, strategy, editorLocale));
630
+ onChange(buildListUrl(type));
631
+ setOpen(false);
632
+ return;
633
+ }
634
+ if (strategy === "featured") {
635
+ setSelectedLabel(strategyLabel(queryStrategyConfigs, type, strategy, editorLocale));
636
+ onChange(buildListUrl(type, { featured: "1" }));
637
+ setOpen(false);
638
+ }
639
+ };
640
+ const handleSelectFilterOption = (option) => {
641
+ if (!isListStrategyResourceType(activeType))
642
+ return;
643
+ if (activeStrategy !== "by_tag")
644
+ return;
645
+ const params = { tag: option.value };
646
+ const nextUrl = buildListUrl(activeType, params);
647
+ setSelectedLabel(`${strategyLabel(queryStrategyConfigs, activeType, activeStrategy, editorLocale)} · ${option.label}`);
648
+ onChange(nextUrl);
649
+ setOpen(false);
650
+ };
651
+ const handleExternalConfirm = () => {
652
+ if (!isValidExternalUrlInput(externalInput))
653
+ return;
654
+ setSelectedLabel("");
655
+ onChange(externalInput.trim());
656
+ setOpen(false);
657
+ };
658
+ const trimmedExternalInput = externalInput.trim();
659
+ const isExternalInputValid = isValidExternalUrlInput(externalInput);
660
+ const externalInputErrorMessage = isActiveExternal && trimmedExternalInput !== "" && !isExternalInputValid ? labels.invalidUrl : "";
661
+ const helperMessage = searchFailed ? labels.searchFailed : "";
662
+ const filterHelperMessage = filterSearchFailed ? labels.searchFailed : "";
663
+ const listOnlyStrategies = ["latest", "featured", "by_tag"];
664
+ return (_jsxs("div", { style: { position: "relative" }, children: [_jsxs("button", { ref: triggerRef, type: "button", style: triggerStyle, title: displayLabel, onClick: () => setOpen((prev) => !prev), children: [_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", style: { width: 13, height: 13, flexShrink: 0, opacity: 0.5 }, children: _jsx("path", { d: "M10.5 13.5 13.5 10.5M8.5 11.5l-1.2 1.2a3 3 0 0 0 4.2 4.2l1.2-1.2M15.5 12.5l1.2-1.2a3 3 0 0 0-4.2-4.2l-1.2 1.2", fill: "none", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2" }) }), _jsx("span", { style: {
665
+ display: "inline-block",
666
+ flex: 1,
667
+ overflow: "hidden",
668
+ textOverflow: "ellipsis",
669
+ whiteSpace: "nowrap",
670
+ }, children: displayLabel }), _jsx("span", { "aria-hidden": "true", style: { opacity: 0.5, fontSize: 10 }, children: "\u21C5" })] }), isOpen ? (_jsxs(_Fragment, { children: [_jsxs("div", { ref: panelRef, style: {
671
+ ...rootPanelStyle,
672
+ top: panelPos?.rootTop ?? 0,
673
+ left: panelPos?.rootLeft ?? 0,
674
+ visibility: panelPos ? "visible" : "hidden",
675
+ }, children: [virtualResourceTypes.map((virtualType) => {
676
+ const isActive = activeVirtualType === virtualType;
677
+ return (_jsxs("button", { type: "button", style: isActive ? itemActiveStyle : itemStyleBase, onMouseEnter: () => handleTypeTabChange(virtualType), onClick: () => handleTypeTabChange(virtualType), children: [_jsx("span", { children: resourceTypeLabel(virtualType, editorLocale) }), _jsx("span", { "aria-hidden": "true", style: { opacity: 0.4, fontSize: 10 }, children: "\u203A" })] }, virtualType));
678
+ }), _jsxs("button", { type: "button", style: isActiveExternal ? itemActiveStyle : itemStyleBase, onMouseEnter: () => {
679
+ setActiveVirtualType(EXTERNAL_URL_TYPE);
680
+ setActiveStrategy("manual");
681
+ }, onClick: () => {
682
+ setActiveVirtualType(EXTERNAL_URL_TYPE);
683
+ setActiveStrategy("manual");
684
+ }, children: [_jsx("span", { children: labels.externalUrl }), _jsx("span", { "aria-hidden": "true", style: { opacity: 0.4, fontSize: 10 }, children: "\u203A" })] })] }), _jsx("div", { ref: subPanelRef, style: {
685
+ ...subPanelStyle,
686
+ top: panelPos?.subTop ?? 0,
687
+ left: panelPos?.subLeft ?? 0,
688
+ visibility: panelPos ? "visible" : "hidden",
689
+ }, children: isActiveExternal ? (_jsx(ExternalSubpanel, { value: externalInput, onChange: setExternalInput, onConfirm: handleExternalConfirm, isValid: isExternalInputValid, errorMessage: externalInputErrorMessage, confirmLabel: labels.confirm })) : supportsListStrategies ? (_jsx(ListStrategySubpanel, { strategies: listOnlyStrategies, activeStrategy: activeStrategy, onActivateStrategy: (strategy) => {
690
+ setActiveStrategy(strategy);
691
+ if (strategy === "latest" || strategy === "featured") {
692
+ handlePickListStrategy(activeType, strategy);
693
+ }
694
+ }, strategyLabelFor: (strategy) => strategyLabel(queryStrategyConfigs, activeType, strategy, editorLocale), selectedStrategy: selectedStrategyForType(activeType), filterQuery: filterQuery, onFilterQueryChange: setFilterQuery, filterOptions: filterOptions, filterLoading: filterLoading, filterHasSearched: filterHasSearched, filterHelperMessage: filterHelperMessage, onSelectFilterOption: handleSelectFilterOption, searchPlaceholder: labels.search, searchingMessage: labels.searching, noResultsMessage: labels.noResults })) : (_jsx(ManualSearchSubpanel, { query: query, onQueryChange: setQuery, options: options, loading: loading, hasSearched: hasSearched, helperMessage: helperMessage, onScroll: handleOptionsScroll, onSelect: handleSelectResource, hasMoreOptions: hasMoreOptions, isLoadingMore: isLoadingMore, loadMoreFailed: loadMoreFailed, onRetryLoadMore: () => void loadMoreOptions(), currentValue: currentValue, searchPlaceholder: labels.search, searchingMessage: labels.searching, noResultsMessage: labels.noResults, retryLabel: labels.retry, loadMoreLabel: labels.loadMore })) })] })) : null] }));
695
+ }
696
+ // ---------------------------------------------------------------------------
697
+ // Sub-panels
698
+ // ---------------------------------------------------------------------------
699
+ function ExternalSubpanel({ value, onChange, onConfirm, isValid, errorMessage, confirmLabel, }) {
700
+ return (_jsxs(_Fragment, { children: [_jsx("input", { type: "url", autoFocus: true, style: {
701
+ ...inputStyle,
702
+ borderColor: !isValid && value.trim() !== "" ? "#d04949" : inputStyle.border,
703
+ }, value: value, placeholder: "https://example.com", onChange: (event) => onChange(event.target.value), onKeyDown: (event) => {
704
+ event.stopPropagation();
705
+ if (event.key === "Enter" && isValid) {
706
+ onConfirm();
707
+ }
708
+ }, "aria-invalid": !isValid && value.trim() !== "" }), errorMessage !== "" ? (_jsx("div", { style: { color: "#d04949", fontSize: 11, padding: "0 4px" }, children: errorMessage })) : null, _jsx("button", { type: "button", style: buttonPrimaryStyle, onClick: onConfirm, disabled: !isValid, children: confirmLabel })] }));
709
+ }
710
+ function ManualSearchSubpanel({ query, onQueryChange, options, loading, hasSearched, helperMessage, onScroll, onSelect, hasMoreOptions, isLoadingMore, loadMoreFailed, onRetryLoadMore, currentValue, searchPlaceholder, searchingMessage, noResultsMessage, retryLabel, loadMoreLabel, }) {
711
+ return (_jsxs(_Fragment, { children: [_jsx("input", { type: "text", autoFocus: true, style: inputStyle, value: query, placeholder: searchPlaceholder, onChange: (event) => onQueryChange(event.target.value), onKeyDown: (event) => event.stopPropagation() }), _jsx("div", { style: { maxHeight: 240, minHeight: 120, overflowY: "auto", paddingRight: 4 }, onScroll: onScroll, children: loading ? (_jsx("div", { style: helperTextStyle, children: searchingMessage })) : hasSearched && options.length === 0 ? (_jsx("div", { style: helperTextStyle, children: noResultsMessage })) : (options.map((option) => {
712
+ const optionUrl = option.url ?? option.value;
713
+ const isSelected = currentValue === optionUrl;
714
+ return (_jsxs("button", { type: "button", style: isSelected ? itemActiveStyle : itemStyleBase, onClick: () => onSelect(option), children: [_jsx("span", { style: { flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: option.label }), isSelected ? _jsx("span", { style: { fontSize: 10 }, children: "\u2713" }) : null] }, option.value));
715
+ })) }), hasMoreOptions && loadMoreFailed ? (_jsx("button", { type: "button", style: itemStyleBase, disabled: isLoadingMore, onClick: onRetryLoadMore, children: isLoadingMore ? searchingMessage : retryLabel })) : null, hasMoreOptions && !loadMoreFailed ? (_jsx("button", { type: "button", style: itemStyleBase, disabled: isLoadingMore, onClick: onRetryLoadMore, children: isLoadingMore ? searchingMessage : loadMoreLabel })) : null, helperMessage !== "" ? _jsx("div", { style: helperTextStyle, children: helperMessage }) : null] }));
716
+ }
717
+ function ListStrategySubpanel({ strategies, activeStrategy, onActivateStrategy, strategyLabelFor, selectedStrategy, filterQuery, onFilterQueryChange, filterOptions, filterLoading, filterHasSearched, filterHelperMessage, onSelectFilterOption, searchPlaceholder, searchingMessage, noResultsMessage, }) {
718
+ return (_jsxs(_Fragment, { children: [_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 2 }, children: strategies.map((strategy) => {
719
+ const isSelected = selectedStrategy === strategy;
720
+ const isActive = activeStrategy === strategy;
721
+ const style = isSelected || isActive ? itemActiveStyle : itemStyleBase;
722
+ return (_jsxs("button", { type: "button", style: style, onClick: () => onActivateStrategy(strategy), children: [_jsx("span", { children: strategyLabelFor(strategy) }), isSelected ? _jsx("span", { style: { fontSize: 10 }, children: "\u2713" }) : null] }, strategy));
723
+ }) }), activeStrategy === "by_tag" ? (_jsxs(_Fragment, { children: [_jsx("input", { type: "text", autoFocus: true, style: inputStyle, value: filterQuery, placeholder: searchPlaceholder, onChange: (event) => onFilterQueryChange(event.target.value), onKeyDown: (event) => event.stopPropagation() }), _jsx("div", { style: { maxHeight: 200, minHeight: 100, overflowY: "auto", paddingRight: 4 }, children: filterLoading ? (_jsx("div", { style: helperTextStyle, children: searchingMessage })) : filterHasSearched && filterOptions.length === 0 ? (_jsx("div", { style: helperTextStyle, children: noResultsMessage })) : (filterOptions.map((option) => (_jsx("button", { type: "button", style: itemStyleBase, onClick: () => onSelectFilterOption(option), children: _jsx("span", { style: { flex: 1, overflow: "hidden", textOverflow: "ellipsis" }, children: option.label }) }, option.value)))) }), filterHelperMessage !== "" ? _jsx("div", { style: helperTextStyle, children: filterHelperMessage }) : null] })) : null] }));
724
+ }
725
+ // ---------------------------------------------------------------------------
726
+ // Field factory
727
+ // ---------------------------------------------------------------------------
728
+ function UrlFieldRender({ value, onChange, }) {
729
+ return _jsx(UrlPicker, { value: value ?? "", onChange: onChange });
730
+ }
731
+ export function urlField(label) {
732
+ const field = {
733
+ type: "custom",
734
+ metadata: { sudaField: "url" },
735
+ render: ({ value, onChange }) => _jsx(UrlFieldRender, { value: value, onChange: onChange }),
736
+ };
737
+ if (label !== undefined) {
738
+ field.label = label;
739
+ }
740
+ return field;
741
+ }
742
+ //# sourceMappingURL=url-field.js.map