@emdash-cms/admin 0.16.0 → 0.17.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 (96) hide show
  1. package/dist/{LocaleDirectionProvider-BNEVyvgW.js → LocaleDirectionProvider-Cj_AuFni.js} +23 -17
  2. package/dist/LocaleDirectionProvider-Cj_AuFni.js.map +1 -0
  3. package/dist/{config-ZP4qGbGk.d.ts → config-BRdaN73J.d.ts} +1 -1
  4. package/dist/{config-ZP4qGbGk.d.ts.map → config-BRdaN73J.d.ts.map} +1 -1
  5. package/dist/index.d.ts +142 -8
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1680 -199
  8. package/dist/index.js.map +1 -1
  9. package/dist/locales/ar/messages.mjs +1 -1
  10. package/dist/locales/de/messages.mjs +1 -1
  11. package/dist/locales/en/messages.mjs +1 -1
  12. package/dist/locales/es-419/messages.mjs +1 -1
  13. package/dist/locales/es-ES/messages.mjs +1 -1
  14. package/dist/locales/eu/messages.mjs +1 -1
  15. package/dist/locales/fa/messages.mjs +1 -1
  16. package/dist/locales/fr/messages.mjs +1 -1
  17. package/dist/locales/id/messages.mjs +1 -1
  18. package/dist/locales/index.d.ts +1 -1
  19. package/dist/locales/index.js +1 -1
  20. package/dist/locales/ja/messages.mjs +1 -1
  21. package/dist/locales/ko/messages.mjs +1 -1
  22. package/dist/locales/pl/messages.mjs +1 -1
  23. package/dist/locales/pseudo/messages.mjs +1 -1
  24. package/dist/locales/pt-BR/messages.mjs +1 -1
  25. package/dist/locales/th/messages.mjs +1 -0
  26. package/dist/locales/zh-CN/messages.mjs +1 -1
  27. package/dist/locales/zh-TW/messages.mjs +1 -1
  28. package/dist/messages-68Jb5F6d.js +6 -0
  29. package/dist/messages-68Jb5F6d.js.map +1 -0
  30. package/dist/messages-BG5ticgt.js +6 -0
  31. package/dist/messages-BG5ticgt.js.map +1 -0
  32. package/dist/messages-BKmEGNNr.js +6 -0
  33. package/dist/messages-BKmEGNNr.js.map +1 -0
  34. package/dist/messages-BLyCd3bH.js +6 -0
  35. package/dist/messages-BLyCd3bH.js.map +1 -0
  36. package/dist/messages-BPzwf13U.js +6 -0
  37. package/dist/messages-BPzwf13U.js.map +1 -0
  38. package/dist/messages-BunNlQic.js +6 -0
  39. package/dist/messages-BunNlQic.js.map +1 -0
  40. package/dist/messages-C0p_fLTL.js +6 -0
  41. package/dist/messages-C0p_fLTL.js.map +1 -0
  42. package/dist/messages-C3wS1VMq.js +6 -0
  43. package/dist/messages-C3wS1VMq.js.map +1 -0
  44. package/dist/messages-CVN814Vy.js +6 -0
  45. package/dist/messages-CVN814Vy.js.map +1 -0
  46. package/dist/messages-Cxf80Xeu.js +6 -0
  47. package/dist/messages-Cxf80Xeu.js.map +1 -0
  48. package/dist/messages-D3HCqTUM.js +6 -0
  49. package/dist/messages-D3HCqTUM.js.map +1 -0
  50. package/dist/messages-DM8WFh_S.js +6 -0
  51. package/dist/messages-DM8WFh_S.js.map +1 -0
  52. package/dist/messages-Du-_Uh8B.js +6 -0
  53. package/dist/messages-Du-_Uh8B.js.map +1 -0
  54. package/dist/messages-DzQ203Bb.js +6 -0
  55. package/dist/messages-DzQ203Bb.js.map +1 -0
  56. package/dist/messages-amIeQ8HY.js +6 -0
  57. package/dist/messages-amIeQ8HY.js.map +1 -0
  58. package/dist/messages-ngvpRuG5.js +6 -0
  59. package/dist/messages-ngvpRuG5.js.map +1 -0
  60. package/dist/messages-rxZmXkNz.js +6 -0
  61. package/dist/messages-rxZmXkNz.js.map +1 -0
  62. package/dist/styles.css +1 -1
  63. package/package.json +3 -3
  64. package/dist/LocaleDirectionProvider-BNEVyvgW.js.map +0 -1
  65. package/dist/messages-6iBQvP4l.js +0 -6
  66. package/dist/messages-6iBQvP4l.js.map +0 -1
  67. package/dist/messages-B3qgKgae.js +0 -6
  68. package/dist/messages-B3qgKgae.js.map +0 -1
  69. package/dist/messages-B8kb5Oko.js +0 -6
  70. package/dist/messages-B8kb5Oko.js.map +0 -1
  71. package/dist/messages-BhSzAEGL.js +0 -6
  72. package/dist/messages-BhSzAEGL.js.map +0 -1
  73. package/dist/messages-C0wfBdmD.js +0 -6
  74. package/dist/messages-C0wfBdmD.js.map +0 -1
  75. package/dist/messages-CboPeBM0.js +0 -6
  76. package/dist/messages-CboPeBM0.js.map +0 -1
  77. package/dist/messages-CehVxd9q.js +0 -6
  78. package/dist/messages-CehVxd9q.js.map +0 -1
  79. package/dist/messages-D2R4PbiQ.js +0 -6
  80. package/dist/messages-D2R4PbiQ.js.map +0 -1
  81. package/dist/messages-DHha7f8K.js +0 -6
  82. package/dist/messages-DHha7f8K.js.map +0 -1
  83. package/dist/messages-DIehX9BP.js +0 -6
  84. package/dist/messages-DIehX9BP.js.map +0 -1
  85. package/dist/messages-DKdkwHEw.js +0 -6
  86. package/dist/messages-DKdkwHEw.js.map +0 -1
  87. package/dist/messages-DMJobUj_.js +0 -6
  88. package/dist/messages-DMJobUj_.js.map +0 -1
  89. package/dist/messages-Ddgurz-D.js +0 -6
  90. package/dist/messages-Ddgurz-D.js.map +0 -1
  91. package/dist/messages-LeijTgr3.js +0 -6
  92. package/dist/messages-LeijTgr3.js.map +0 -1
  93. package/dist/messages-S5UPBqIz.js +0 -6
  94. package/dist/messages-S5UPBqIz.js.map +0 -1
  95. package/dist/messages-vQ-B-9ct.js +0 -6
  96. package/dist/messages-vQ-B-9ct.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { c as fetchAuthMode, d as throwResponseError, i as fetchPlugins, l as fetchManifest, n as enablePlugin, o as API_BASE, r as fetchPlugin, s as apiFetch, t as disablePlugin, u as parseApiResponse } from "./plugins-BZzztFdK.js";
2
- import { a as SUPPORTED_LOCALES, i as DEFAULT_LOCALE, l as resolveLocale, n as useLocale, o as SUPPORTED_LOCALE_CODES, t as LocaleDirectionProvider } from "./LocaleDirectionProvider-BNEVyvgW.js";
2
+ import { a as SUPPORTED_LOCALES, i as DEFAULT_LOCALE, l as resolveLocale, n as useLocale, o as SUPPORTED_LOCALE_CODES, t as LocaleDirectionProvider } from "./LocaleDirectionProvider-Cj_AuFni.js";
3
3
  import "./locales/index.js";
4
4
  import { Autocomplete, Badge, Button, Checkbox, CommandPalette, Dialog, Input, InputArea, Label, LinkButton, Loader, Popover, Select, Sidebar as KumoSidebar, Switch, Tabs, Toast, Toasty, Tooltip, buttonVariants, useSidebar } from "@cloudflare/kumo";
5
5
  import { i18n } from "@lingui/core";
6
6
  import { I18nProvider, Trans, useLingui } from "@lingui/react";
7
- import { QueryClient, QueryClientProvider, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7
+ import { QueryClient, QueryClientProvider, keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
8
  import { Link, Link as Link$1, Outlet, RouterProvider, createRootRouteWithContext, createRoute, createRouter, useLocation, useNavigate, useNavigate as useNavigate$1, useParams, useParams as useParams$1, useSearch } from "@tanstack/react-router";
9
9
  import * as React$1 from "react";
10
10
  import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
11
11
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
12
  import { Dialog as Dialog$1 } from "@cloudflare/kumo/primitives";
13
- import { ArrowCounterClockwise, ArrowRight, ArrowRightIcon, ArrowSquareOut, ArrowUUpLeft, ArrowUUpRight, ArrowsClockwise, ArrowsInSimple, ArrowsLeftRight, ArrowsOutSimple, BracketsCurly, Calendar, CalendarBlank, CaretDown, CaretRightIcon, CaretUp, CaretUpDown, ChatCircle, Check, CheckCircle, CircleDashed, CircleNotch, ClockCounterClockwise, Cloud, Code, CodeBlock, Columns, Copy, Cube, Database, DeviceMobile, DotsSixVertical, DownloadSimple, Envelope, EnvelopeSimple, Eye, EyeSlash, File, FileArrowDown, FileText, FloppyDisk, FolderOpen, Gear, GithubLogo, Globe, GlobeSimple, GridFour, HardDrive, Hash, Image as Image$1, ImageBroken, ImageSquare, Info, Key, Link as Link$2, LinkBreak, LinkSimple, List, ListBullets, ListChecks, ListNumbers, MagnifyingGlass, Minus, Monitor, Moon, Palette, PaperPlaneTilt, Paperclip, Paragraph, Pencil, PencilSimple, PlugsConnected, Plus, Prohibit, PuzzlePiece, Quotes, Rows, Ruler, ShareNetwork, Shield, ShieldCheck, ShieldWarning, SignOut, SlidersHorizontal, Sparkle, SquaresFour, Stack, Storefront, Sun, Table, TextAlignCenter, TextAlignLeft, TextAlignRight, TextB, TextHOne, TextHThree, TextHTwo, TextItalic, TextStrikethrough, TextT, TextUnderline, ToggleLeft, Trash, Upload, User, UserCircle, UserPlus, Users, Warning, WarningCircle, WebhooksLogo, X, YoutubeLogo } from "@phosphor-icons/react";
13
+ import { ArrowCounterClockwise, ArrowDown, ArrowRight, ArrowRightIcon, ArrowSquareOut, ArrowUUpLeft, ArrowUUpRight, ArrowUp, ArrowsClockwise, ArrowsInSimple, ArrowsLeftRight, ArrowsOutSimple, BracketsAngle, BracketsCurly, Calendar, CalendarBlank, CaretDown, CaretRightIcon, CaretUp, CaretUpDown, ChatCircle, Check, CheckCircle, CircleDashed, CircleNotch, ClockCounterClockwise, Cloud, Code, CodeBlock, Columns, Copy, Cube, Database, DeviceMobile, DotsSixVertical, DownloadSimple, Envelope, EnvelopeSimple, Eye, EyeSlash, File, FileArrowDown, FileText, FloppyDisk, FolderOpen, Gear, GithubLogo, Globe, GlobeSimple, GridFour, HardDrive, Hash, IdentificationCard, Image as Image$1, ImageBroken, ImageSquare, Info, Key, Link as Link$2, LinkBreak, LinkSimple, List, ListBullets, ListChecks, ListNumbers, MagnifyingGlass, Minus, Monitor, Moon, Palette, PaperPlaneTilt, Paperclip, Paragraph, Pencil, PencilSimple, PlugsConnected, Plus, Prohibit, PuzzlePiece, Quotes, Rows, Ruler, ShareNetwork, Shield, ShieldCheck, ShieldWarning, SignOut, SlidersHorizontal, Sparkle, SquaresFour, Stack, Storefront, Sun, Table, TextAlignCenter, TextAlignLeft, TextAlignRight, TextB, TextHOne, TextHThree, TextHTwo, TextItalic, TextStrikethrough, TextT, TextUnderline, ToggleLeft, Trash, Upload, User, UserCircle, UserPlus, Users, Warning, WarningCircle, WebhooksLogo, X, YoutubeLogo } from "@phosphor-icons/react";
14
14
  import { clsx } from "clsx";
15
15
  import { twMerge } from "tailwind-merge";
16
16
  import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, rectIntersection, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
@@ -30,7 +30,7 @@ import Typography from "@tiptap/extension-typography";
30
30
  import { EditorContent, Node as Node$2, NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes as mergeAttributes$1, useEditor, useEditorState } from "@tiptap/react";
31
31
  import { BubbleMenu } from "@tiptap/react/menus";
32
32
  import StarterKit from "@tiptap/starter-kit";
33
- import { Plugin, PluginKey } from "@tiptap/pm/state";
33
+ import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
34
34
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
35
35
  import { createPortal } from "react-dom";
36
36
  import CodeBlock$1 from "@tiptap/extension-code-block";
@@ -1178,6 +1178,7 @@ async function fetchContentList(collection, options) {
1178
1178
  if (options?.locale) params.set("locale", options.locale);
1179
1179
  if (options?.orderBy) params.set("orderBy", options.orderBy);
1180
1180
  if (options?.order) params.set("order", options.order);
1181
+ if (options?.search) params.set("q", options.search);
1181
1182
  return parseApiResponse(await apiFetch(`${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`), "Failed to fetch content");
1182
1183
  }
1183
1184
  /**
@@ -1372,6 +1373,15 @@ async function restoreRevision(revisionId) {
1372
1373
  * Media upload, list, delete, and provider APIs
1373
1374
  */
1374
1375
  /**
1376
+ * Maximum length of the media filename search term. Mirrors the server-side
1377
+ * zod schema (`q: z.string().trim().min(1).max(200)`); keep in sync.
1378
+ */
1379
+ const MEDIA_SEARCH_MAX_LENGTH = 200;
1380
+ /** Trim and clamp a search term to the server-accepted range. */
1381
+ function normalizeMediaSearch(value) {
1382
+ return (value ?? "").trim().slice(0, MEDIA_SEARCH_MAX_LENGTH);
1383
+ }
1384
+ /**
1375
1385
  * Fetch media list
1376
1386
  */
1377
1387
  async function fetchMediaList(options) {
@@ -1382,6 +1392,10 @@ async function fetchMediaList(options) {
1382
1392
  const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType;
1383
1393
  if (value) params.set("mimeType", value);
1384
1394
  }
1395
+ if (options?.search) {
1396
+ const q = normalizeMediaSearch(options.search);
1397
+ if (q) params.set("q", q);
1398
+ }
1385
1399
  return parseApiResponse(await apiFetch(`${API_BASE}/media${params.toString() ? `?${params}` : ""}`), "Failed to fetch media");
1386
1400
  }
1387
1401
  /**
@@ -2008,6 +2022,70 @@ async function createBylineTranslation(id, input) {
2008
2022
  }), "Failed to create byline translation");
2009
2023
  }
2010
2024
 
2025
+ //#endregion
2026
+ //#region src/lib/api/byline-fields.ts
2027
+ /**
2028
+ * Byline custom-field schema management API (Discussion #1174, Phase 4).
2029
+ *
2030
+ * Mirrors the server-side admin endpoints at
2031
+ * `/_emdash/api/admin/byline-fields/*`. Mutation responses use the
2032
+ * shared `ApiResult<T>` envelope; this client unwraps it via
2033
+ * `parseApiResponse` and surfaces typed errors through
2034
+ * `throwResponseError` (so the admin client sees the registry's
2035
+ * `FIELD_EXISTS` / `TRANSLATABLE_LOCKED` / `REORDER_MISMATCH` messages
2036
+ * verbatim).
2037
+ */
2038
+ const BASE = `${API_BASE}/admin/byline-fields`;
2039
+ async function listBylineFields() {
2040
+ return parseApiResponse(await apiFetch(BASE), i18n._({
2041
+ id: "rLmrr+",
2042
+ message: "Failed to list byline fields"
2043
+ }));
2044
+ }
2045
+ async function getBylineFieldUsage(slug) {
2046
+ return parseApiResponse(await apiFetch(`${BASE}/${encodeURIComponent(slug)}/usage`), i18n._({
2047
+ id: "DwFS6c",
2048
+ message: "Failed to read byline field usage"
2049
+ }));
2050
+ }
2051
+ async function createBylineField(input) {
2052
+ return parseApiResponse(await apiFetch(BASE, {
2053
+ method: "POST",
2054
+ headers: { "Content-Type": "application/json" },
2055
+ body: JSON.stringify(input)
2056
+ }), i18n._({
2057
+ id: "cPkE9I",
2058
+ message: "Failed to create byline field"
2059
+ }));
2060
+ }
2061
+ async function updateBylineField(slug, input) {
2062
+ return parseApiResponse(await apiFetch(`${BASE}/${encodeURIComponent(slug)}`, {
2063
+ method: "PATCH",
2064
+ headers: { "Content-Type": "application/json" },
2065
+ body: JSON.stringify(input)
2066
+ }), i18n._({
2067
+ id: "1dl1C5",
2068
+ message: "Failed to update byline field"
2069
+ }));
2070
+ }
2071
+ async function deleteBylineField(slug) {
2072
+ const response = await apiFetch(`${BASE}/${encodeURIComponent(slug)}`, { method: "DELETE" });
2073
+ if (!response.ok) await throwResponseError(response, i18n._({
2074
+ id: "MxBmlg",
2075
+ message: "Failed to delete byline field"
2076
+ }));
2077
+ }
2078
+ async function reorderBylineFields(slugs) {
2079
+ return parseApiResponse(await apiFetch(`${BASE}/reorder`, {
2080
+ method: "POST",
2081
+ headers: { "Content-Type": "application/json" },
2082
+ body: JSON.stringify({ slugs })
2083
+ }), i18n._({
2084
+ id: "J/UlAe",
2085
+ message: "Failed to reorder byline fields"
2086
+ }));
2087
+ }
2088
+
2011
2089
  //#endregion
2012
2090
  //#region src/lib/api/menus.ts
2013
2091
  /**
@@ -2961,6 +3039,35 @@ function fromDatetimeLocalInputValue(value) {
2961
3039
  return value === "" ? "" : `${value}:00.000Z`;
2962
3040
  }
2963
3041
 
3042
+ //#endregion
3043
+ //#region src/lib/hooks.ts
3044
+ /**
3045
+ * Returns a stable function reference that always calls the latest version
3046
+ * of the provided callback. Useful for event listeners in effects where
3047
+ * you don't want the listener to be torn down and re-added when the
3048
+ * callback identity changes.
3049
+ */
3050
+ function useStableCallback(callback) {
3051
+ const ref = React$1.useRef(callback);
3052
+ React$1.useLayoutEffect(() => {
3053
+ ref.current = callback;
3054
+ });
3055
+ return React$1.useCallback((...args) => ref.current(...args), []);
3056
+ }
3057
+ /**
3058
+ * Returns a debounced version of a value that only updates after the
3059
+ * specified delay has elapsed since the last change. Useful for search
3060
+ * inputs that trigger API calls.
3061
+ */
3062
+ function useDebouncedValue$1(value, delay) {
3063
+ const [debouncedValue, setDebouncedValue] = React$1.useState(value);
3064
+ React$1.useEffect(() => {
3065
+ const timer = setTimeout(setDebouncedValue, delay, value);
3066
+ return () => clearTimeout(timer);
3067
+ }, [value, delay]);
3068
+ return debouncedValue;
3069
+ }
3070
+
2964
3071
  //#endregion
2965
3072
  //#region src/lib/media-utils.ts
2966
3073
  function providerItemToMediaItem(providerId, item) {
@@ -3178,6 +3285,7 @@ function MediaPickerModal({ open, onOpenChange, onSelect, mimeTypeFilter = "imag
3178
3285
  const [selectedItem, setSelectedItem] = React$1.useState(null);
3179
3286
  const [activeProvider, setActiveProvider] = React$1.useState("local");
3180
3287
  const [searchQuery, setSearchQuery] = React$1.useState("");
3288
+ const debouncedSearch = useDebouncedValue$1(searchQuery, 300);
3181
3289
  const fileInputRef = React$1.useRef(null);
3182
3290
  const [imageUrl, setImageUrl] = React$1.useState("");
3183
3291
  const [isProbing, setIsProbing] = React$1.useState(false);
@@ -3214,12 +3322,18 @@ function MediaPickerModal({ open, onOpenChange, onSelect, mimeTypeFilter = "imag
3214
3322
  };
3215
3323
  return providers?.find((p) => p.id === activeProvider);
3216
3324
  }, [activeProvider, providers]);
3325
+ const mediaQueryKey = [
3326
+ "media",
3327
+ filters?.join(",") ?? "",
3328
+ debouncedSearch.trim()
3329
+ ];
3217
3330
  const { data: localData, isLoading: localLoading, fetchNextPage: fetchNextLocalPage, hasNextPage: hasNextLocalPage, isFetchingNextPage: isFetchingNextLocalPage } = useInfiniteQuery({
3218
- queryKey: ["media", filters?.join(",") ?? ""],
3331
+ queryKey: mediaQueryKey,
3219
3332
  queryFn: ({ pageParam }) => fetchMediaList({
3220
3333
  mimeType: filters,
3221
3334
  cursor: pageParam,
3222
- limit: 100
3335
+ limit: 100,
3336
+ search: debouncedSearch.trim() || void 0
3223
3337
  }),
3224
3338
  initialPageParam: void 0,
3225
3339
  getNextPageParam: (lastPage) => lastPage.nextCursor,
@@ -3277,7 +3391,7 @@ function MediaPickerModal({ open, onOpenChange, onSelect, mimeTypeFilter = "imag
3277
3391
  height
3278
3392
  }),
3279
3393
  onSuccess: (_updated, { id, width, height }) => {
3280
- queryClient.setQueryData(["media", filters?.join(",") ?? ""], (old) => {
3394
+ queryClient.setQueryData(mediaQueryKey, (old) => {
3281
3395
  if (!old) return old;
3282
3396
  return {
3283
3397
  ...old,
@@ -3555,11 +3669,14 @@ function MediaPickerModal({ open, onOpenChange, onSelect, mimeTypeFilter = "imag
3555
3669
  }),
3556
3670
  /* @__PURE__ */ jsxs("div", {
3557
3671
  className: "flex items-center justify-between pb-3 gap-4",
3558
- children: [canSearch ? /* @__PURE__ */ jsxs("div", {
3672
+ children: [canSearch || activeProvider === "local" ? /* @__PURE__ */ jsxs("div", {
3559
3673
  className: "relative flex-1 max-w-xs",
3560
3674
  children: [/* @__PURE__ */ jsx(MagnifyingGlass, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" }), /* @__PURE__ */ jsx(Input, {
3561
3675
  type: "search",
3562
- placeholder: _t({
3676
+ placeholder: activeProvider === "local" ? _t({
3677
+ id: "Fwo2VZ",
3678
+ message: "Search by filename..."
3679
+ }) : _t({
3563
3680
  id: "YIix5Y",
3564
3681
  message: "Search..."
3565
3682
  }),
@@ -3569,6 +3686,7 @@ function MediaPickerModal({ open, onOpenChange, onSelect, mimeTypeFilter = "imag
3569
3686
  }),
3570
3687
  value: searchQuery,
3571
3688
  onChange: (e) => setSearchQuery(e.target.value),
3689
+ maxLength: MEDIA_SEARCH_MAX_LENGTH,
3572
3690
  className: "ps-9"
3573
3691
  })]
3574
3692
  }) : /* @__PURE__ */ jsx("p", {
@@ -4596,35 +4714,6 @@ function renderIcon(icon) {
4596
4714
  return /* @__PURE__ */ jsx(icon, {});
4597
4715
  }
4598
4716
 
4599
- //#endregion
4600
- //#region src/lib/hooks.ts
4601
- /**
4602
- * Returns a stable function reference that always calls the latest version
4603
- * of the provided callback. Useful for event listeners in effects where
4604
- * you don't want the listener to be torn down and re-added when the
4605
- * callback identity changes.
4606
- */
4607
- function useStableCallback(callback) {
4608
- const ref = React$1.useRef(callback);
4609
- React$1.useLayoutEffect(() => {
4610
- ref.current = callback;
4611
- });
4612
- return React$1.useCallback((...args) => ref.current(...args), []);
4613
- }
4614
- /**
4615
- * Returns a debounced version of a value that only updates after the
4616
- * specified delay has elapsed since the last change. Useful for search
4617
- * inputs that trigger API calls.
4618
- */
4619
- function useDebouncedValue$1(value, delay) {
4620
- const [debouncedValue, setDebouncedValue] = React$1.useState(value);
4621
- React$1.useEffect(() => {
4622
- const timer = setTimeout(setDebouncedValue, delay, value);
4623
- return () => clearTimeout(timer);
4624
- }, [value, delay]);
4625
- return debouncedValue;
4626
- }
4627
-
4628
4717
  //#endregion
4629
4718
  //#region src/components/editor/ImageDetailPanel.tsx
4630
4719
  /**
@@ -6086,19 +6175,25 @@ function BlockMenu({ editor, anchorElement, isOpen, onClose }) {
6086
6175
  if (!isOpen) setShowTransforms(false);
6087
6176
  }, [isOpen]);
6088
6177
  const handleDuplicate = () => {
6089
- const { selection } = editor.state;
6090
- const { $from, $to } = selection;
6091
- const blockStart = $from.start($from.depth);
6092
- const blockEnd = $to.end($to.depth);
6093
- const slice = editor.state.doc.slice(blockStart, blockEnd);
6178
+ if (!(editor.state.selection instanceof NodeSelection)) {
6179
+ onClose();
6180
+ return;
6181
+ }
6182
+ const { selection, doc } = editor.state;
6183
+ const { from, to } = selection;
6184
+ const slice = doc.slice(from, to);
6094
6185
  editor.chain().focus().command(({ tr }) => {
6095
- tr.insert(blockEnd + 1, slice.content);
6186
+ tr.insert(to, slice.content);
6096
6187
  return true;
6097
6188
  }).run();
6098
6189
  onClose();
6099
6190
  };
6100
6191
  const handleDelete = () => {
6101
- editor.chain().focus().deleteNode(editor.state.selection.$from.parent.type.name).run();
6192
+ if (!(editor.state.selection instanceof NodeSelection)) {
6193
+ onClose();
6194
+ return;
6195
+ }
6196
+ editor.chain().focus().deleteSelection().run();
6102
6197
  onClose();
6103
6198
  };
6104
6199
  const handleTransform = (transform) => {
@@ -6239,6 +6334,173 @@ function DragHandleWrapper({ editor }) {
6239
6334
  })] });
6240
6335
  }
6241
6336
 
6337
+ //#endregion
6338
+ //#region src/components/editor/HtmlBlockNode.tsx
6339
+ /**
6340
+ * HTML block node for the admin editor.
6341
+ *
6342
+ * Renders a first-class `htmlBlock` in the Portable Text editor with:
6343
+ * - A textarea for editing raw HTML source
6344
+ * - Selection ring, drag handle, and delete action
6345
+ *
6346
+ * Modeled on `PluginBlockNode.tsx` (atom node with React node view) and
6347
+ * the existing `{ _type: "htmlBlock", _key, html }` Portable Text shape
6348
+ * used by the WordPress and Contentful importers.
6349
+ */
6350
+ /**
6351
+ * True when focus is inside a text input or textarea (e.g. the HTML block's
6352
+ * source <textarea>). Editor-level keyboard shortcuts must defer to native
6353
+ * field editing in that case.
6354
+ */
6355
+ function isEditingFormField() {
6356
+ if (typeof document === "undefined") return false;
6357
+ const active = document.activeElement;
6358
+ if (!active) return false;
6359
+ const tag = active.tagName;
6360
+ return tag === "TEXTAREA" || tag === "INPUT";
6361
+ }
6362
+ function HtmlBlockNodeView({ node, updateAttributes, selected, deleteNode }) {
6363
+ const { _: _t } = useLingui();
6364
+ const html = typeof node.attrs.html === "string" ? node.attrs.html : "";
6365
+ const [draft, setDraft] = React$1.useState(html);
6366
+ const textareaRef = React$1.useRef(null);
6367
+ React$1.useEffect(() => {
6368
+ setDraft(html);
6369
+ }, [html]);
6370
+ React$1.useEffect(() => {
6371
+ const el = textareaRef.current;
6372
+ if (el) {
6373
+ el.style.height = "auto";
6374
+ el.style.height = `${el.scrollHeight}px`;
6375
+ }
6376
+ }, [draft]);
6377
+ const commitHtml = React$1.useCallback((value) => {
6378
+ updateAttributes({ html: value });
6379
+ }, [updateAttributes]);
6380
+ const handleChange = React$1.useCallback((e) => {
6381
+ setDraft(e.target.value);
6382
+ commitHtml(e.target.value);
6383
+ }, [commitHtml]);
6384
+ return /* @__PURE__ */ jsx(NodeViewWrapper, {
6385
+ className: cn("html-block relative my-3", selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg"),
6386
+ contentEditable: false,
6387
+ "data-drag-handle": true,
6388
+ children: /* @__PURE__ */ jsxs("div", {
6389
+ className: "relative group",
6390
+ children: [/* @__PURE__ */ jsx("div", {
6391
+ className: cn("absolute -start-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing", selected && "opacity-100"),
6392
+ "data-drag-handle": true,
6393
+ children: /* @__PURE__ */ jsx(DotsSixVertical, { className: "h-5 w-5 text-kumo-subtle/50" })
6394
+ }), /* @__PURE__ */ jsxs("div", {
6395
+ className: cn("rounded-lg border bg-kumo-base transition-colors overflow-hidden", selected ? "border-kumo-brand/50 bg-kumo-tint/30" : "hover:border-kumo-line"),
6396
+ children: [/* @__PURE__ */ jsxs("div", {
6397
+ className: "flex items-center gap-3 px-4 py-3",
6398
+ children: [
6399
+ /* @__PURE__ */ jsx("div", {
6400
+ className: "flex-shrink-0 w-10 h-10 rounded-lg bg-kumo-tint flex items-center justify-center text-kumo-subtle",
6401
+ children: /* @__PURE__ */ jsx(BracketsAngle, { className: "h-5 w-5" })
6402
+ }),
6403
+ /* @__PURE__ */ jsx("div", {
6404
+ className: "flex-1 min-w-0",
6405
+ children: /* @__PURE__ */ jsx("div", {
6406
+ className: "text-sm font-medium",
6407
+ children: _t({
6408
+ id: "FBknns",
6409
+ message: "HTML"
6410
+ })
6411
+ })
6412
+ }),
6413
+ /* @__PURE__ */ jsx("div", {
6414
+ className: cn("flex items-center gap-1 transition-opacity", selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"),
6415
+ children: /* @__PURE__ */ jsx(Button, {
6416
+ type: "button",
6417
+ variant: "ghost",
6418
+ shape: "square",
6419
+ className: "h-8 w-8 text-kumo-danger hover:text-kumo-danger hover:bg-kumo-danger/10",
6420
+ onClick: () => deleteNode(),
6421
+ title: _t({
6422
+ id: "cnGeoo",
6423
+ message: "Delete"
6424
+ }),
6425
+ "aria-label": _t({
6426
+ id: "p8SVsk",
6427
+ message: "Delete HTML block"
6428
+ }),
6429
+ children: /* @__PURE__ */ jsx(Trash, { className: "h-4 w-4" })
6430
+ })
6431
+ })
6432
+ ]
6433
+ }), /* @__PURE__ */ jsx("div", {
6434
+ className: "px-4 pb-4",
6435
+ children: /* @__PURE__ */ jsx("textarea", {
6436
+ ref: textareaRef,
6437
+ value: draft,
6438
+ onChange: handleChange,
6439
+ placeholder: _t({
6440
+ id: "cquA1F",
6441
+ message: "Enter HTML..."
6442
+ }),
6443
+ className: "w-full min-h-[100px] resize-y rounded-md border bg-kumo-overlay p-3 font-mono text-sm text-kumo-strong placeholder:text-kumo-subtle focus:outline-none focus:ring-2 focus:ring-kumo-brand",
6444
+ spellCheck: false,
6445
+ "aria-label": _t({
6446
+ id: "bbJ01O",
6447
+ message: "HTML source"
6448
+ })
6449
+ })
6450
+ })]
6451
+ })]
6452
+ })
6453
+ });
6454
+ }
6455
+ /**
6456
+ * TipTap extension: first-class HTML block.
6457
+ *
6458
+ * An atom node that stores raw HTML in a `html` attribute. Round-trips
6459
+ * through Portable Text as `{ _type: "htmlBlock", _key, html }`.
6460
+ */
6461
+ const HtmlBlockExtension = Node$1.create({
6462
+ name: "htmlBlock",
6463
+ group: "block",
6464
+ atom: true,
6465
+ draggable: true,
6466
+ selectable: true,
6467
+ addAttributes() {
6468
+ return { html: {
6469
+ default: "",
6470
+ parseHTML: (element) => element.getAttribute("data-html-content") ?? "",
6471
+ renderHTML: (attributes) => {
6472
+ const html = typeof attributes.html === "string" ? attributes.html : "";
6473
+ if (!html) return {};
6474
+ return { "data-html-content": html };
6475
+ }
6476
+ } };
6477
+ },
6478
+ parseHTML() {
6479
+ return [{ tag: "div[data-html-block]" }];
6480
+ },
6481
+ renderHTML({ HTMLAttributes }) {
6482
+ return ["div", mergeAttributes(HTMLAttributes, { "data-html-block": "" })];
6483
+ },
6484
+ addNodeView() {
6485
+ return ReactNodeViewRenderer(HtmlBlockNodeView);
6486
+ },
6487
+ addKeyboardShortcuts() {
6488
+ const deleteHtmlBlock = () => {
6489
+ if (isEditingFormField()) return false;
6490
+ const { selection } = this.editor.state;
6491
+ if (this.editor.state.doc.nodeAt(selection.from)?.type.name === "htmlBlock") {
6492
+ this.editor.commands.deleteSelection();
6493
+ return true;
6494
+ }
6495
+ return false;
6496
+ };
6497
+ return {
6498
+ Backspace: deleteHtmlBlock,
6499
+ Delete: deleteHtmlBlock
6500
+ };
6501
+ }
6502
+ });
6503
+
6242
6504
  //#endregion
6243
6505
  //#region src/components/editor/ImageNode.tsx
6244
6506
  /**
@@ -7191,6 +7453,14 @@ function convertPMNode(node) {
7191
7453
  language: typeof rawLanguage === "string" ? rawLanguage : void 0
7192
7454
  };
7193
7455
  }
7456
+ case "htmlBlock": {
7457
+ const rawHtml = node.attrs?.html;
7458
+ return {
7459
+ _type: "htmlBlock",
7460
+ _key: generateKey(),
7461
+ html: typeof rawHtml === "string" ? rawHtml : ""
7462
+ };
7463
+ }
7194
7464
  case "image": {
7195
7465
  const attrs = node.attrs ?? {};
7196
7466
  const provider = attrStr(attrs.provider);
@@ -7449,6 +7719,10 @@ function convertPTBlock(block) {
7449
7719
  };
7450
7720
  }
7451
7721
  case "break": return { type: "horizontalRule" };
7722
+ case "htmlBlock": return {
7723
+ type: "htmlBlock",
7724
+ attrs: { html: block.html || "" }
7725
+ };
7452
7726
  case "table": {
7453
7727
  const tableBlock = block;
7454
7728
  const tableMarkDefs = tableBlock.markDefs || [];
@@ -7688,6 +7962,29 @@ const defaultSlashCommands = [
7688
7962
  editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
7689
7963
  }
7690
7964
  },
7965
+ {
7966
+ id: "htmlBlock",
7967
+ title: {
7968
+ id: "FBknns",
7969
+ message: "HTML"
7970
+ },
7971
+ description: {
7972
+ id: "iHrC09",
7973
+ message: "Insert raw HTML"
7974
+ },
7975
+ icon: BracketsAngle,
7976
+ aliases: [
7977
+ "html",
7978
+ "raw",
7979
+ "markup"
7980
+ ],
7981
+ command: ({ editor, range }) => {
7982
+ editor.chain().focus().deleteRange(range).insertContent({
7983
+ type: "htmlBlock",
7984
+ attrs: { html: "" }
7985
+ }).run();
7986
+ }
7987
+ },
7691
7988
  {
7692
7989
  id: "divider",
7693
7990
  title: {
@@ -8569,6 +8866,7 @@ function PortableTextEditor({ value, onChange, placeholder = "Start writing...",
8569
8866
  underline: {}
8570
8867
  }),
8571
8868
  CodeBlockExtension,
8869
+ HtmlBlockExtension,
8572
8870
  ImageExtension,
8573
8871
  MarkdownLinkExtension,
8574
8872
  PluginBlockExtension,
@@ -8723,7 +9021,7 @@ function PortableTextEditor({ value, onChange, placeholder = "Start writing...",
8723
9021
  })
8724
9022
  });
8725
9023
  return /* @__PURE__ */ jsxs("div", {
8726
- className: cn("border rounded-lg overflow-hidden", minimal && "border-0 rounded-none -mx-4", focusMode === "spotlight" && "spotlight-mode", className),
9024
+ className: cn("border rounded-lg overflow-clip", minimal && "border-0 rounded-none -mx-4", focusMode === "spotlight" && "spotlight-mode", className),
8727
9025
  "aria-labelledby": ariaLabelledby,
8728
9026
  children: [
8729
9027
  !minimal && /* @__PURE__ */ jsx(EditorToolbar, {
@@ -9136,7 +9434,7 @@ function EditorToolbar({ editor, focusMode, onFocusModeChange }) {
9136
9434
  id: "maNyuq",
9137
9435
  message: "Text formatting"
9138
9436
  }),
9139
- className: "border-b bg-kumo-tint/50 p-1 flex flex-wrap gap-0.5",
9437
+ className: "sticky -top-6 z-10 border-b bg-kumo-tint p-1 flex flex-wrap gap-0.5",
9140
9438
  onKeyDown: handleKeyDown,
9141
9439
  children: [
9142
9440
  /* @__PURE__ */ jsxs(ToolbarGroup, { children: [
@@ -10326,10 +10624,11 @@ async function fetchTaxonomyDefs$1() {
10326
10624
  return (await parseApiResponse(await apiFetch(`/_emdash/api/taxonomies`), "Failed to fetch taxonomies")).taxonomies;
10327
10625
  }
10328
10626
  /**
10329
- * Fetch terms for a taxonomy
10627
+ * Fetch terms for a taxonomy, scoped to the entry's locale so only the matching
10628
+ * translation variants are offered.
10330
10629
  */
10331
- async function fetchTerms$1(taxonomyName) {
10332
- return (await parseApiResponse(await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`), i18n._({
10630
+ async function fetchTerms$1(taxonomyName, locale) {
10631
+ return (await parseApiResponse(await apiFetch(withLocale(`/_emdash/api/taxonomies/${taxonomyName}/terms`, locale)), i18n._({
10333
10632
  id: "uvkjA+",
10334
10633
  message: "Failed to fetch terms"
10335
10634
  }))).terms;
@@ -10337,8 +10636,8 @@ async function fetchTerms$1(taxonomyName) {
10337
10636
  /**
10338
10637
  * Fetch entry terms
10339
10638
  */
10340
- async function fetchEntryTerms(collection, entryId, taxonomy) {
10341
- return (await parseApiResponse(await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`), i18n._({
10639
+ async function fetchEntryTerms(collection, entryId, taxonomy, locale) {
10640
+ return (await parseApiResponse(await apiFetch(withLocale(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, locale)), i18n._({
10342
10641
  id: "pbqrlw",
10343
10642
  message: "Failed to fetch entry terms"
10344
10643
  }))).terms;
@@ -10346,8 +10645,8 @@ async function fetchEntryTerms(collection, entryId, taxonomy) {
10346
10645
  /**
10347
10646
  * Set entry terms
10348
10647
  */
10349
- async function setEntryTerms(collection, entryId, taxonomy, termIds) {
10350
- const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, {
10648
+ async function setEntryTerms(collection, entryId, taxonomy, termIds, locale) {
10649
+ const res = await apiFetch(withLocale(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, locale), {
10351
10650
  method: "POST",
10352
10651
  headers: { "Content-Type": "application/json" },
10353
10652
  body: JSON.stringify({ termIds })
@@ -10495,40 +10794,46 @@ function TagInput({ terms, selectedIds, onAdd, onRemove, onCreate, isCreating, l
10495
10794
  /**
10496
10795
  * Single taxonomy section
10497
10796
  */
10498
- function TaxonomySection({ taxonomy, collection, entryId, onChange }) {
10797
+ function TaxonomySection({ taxonomy, collection, entryId, entryLocale, onChange }) {
10499
10798
  const { _: _t2 } = useLingui();
10500
10799
  const queryClient = useQueryClient();
10501
10800
  const toastManager = Toast.useToastManager();
10502
10801
  const [newCategoryLabel, setNewCategoryLabel] = React$1.useState("");
10503
10802
  const [showCategoryInput, setShowCategoryInput] = React$1.useState(false);
10504
10803
  const { data: terms = EMPTY_TERMS } = useQuery({
10505
- queryKey: ["taxonomy-terms", taxonomy.name],
10506
- queryFn: () => fetchTerms$1(taxonomy.name)
10804
+ queryKey: [
10805
+ "taxonomy-terms",
10806
+ taxonomy.name,
10807
+ entryLocale
10808
+ ],
10809
+ queryFn: () => fetchTerms$1(taxonomy.name, entryLocale)
10507
10810
  });
10508
10811
  const { data: entryTerms = EMPTY_TERMS } = useQuery({
10509
10812
  queryKey: [
10510
10813
  "entry-terms",
10511
10814
  collection,
10512
10815
  entryId,
10513
- taxonomy.name
10816
+ taxonomy.name,
10817
+ entryLocale
10514
10818
  ],
10515
10819
  queryFn: () => {
10516
10820
  if (!entryId) return [];
10517
- return fetchEntryTerms(collection, entryId, taxonomy.name);
10821
+ return fetchEntryTerms(collection, entryId, taxonomy.name, entryLocale);
10518
10822
  },
10519
10823
  enabled: !!entryId
10520
10824
  });
10521
10825
  const saveMutation = useMutation({
10522
10826
  mutationFn: (termIds) => {
10523
10827
  if (!entryId) throw new Error("No entry ID");
10524
- return setEntryTerms(collection, entryId, taxonomy.name, termIds);
10828
+ return setEntryTerms(collection, entryId, taxonomy.name, termIds, entryLocale);
10525
10829
  },
10526
10830
  onSuccess: () => {
10527
10831
  queryClient.invalidateQueries({ queryKey: [
10528
10832
  "entry-terms",
10529
10833
  collection,
10530
10834
  entryId,
10531
- taxonomy.name
10835
+ taxonomy.name,
10836
+ entryLocale
10532
10837
  ] });
10533
10838
  toastManager.add({ title: _t2({
10534
10839
  id: "KDffey",
@@ -10554,10 +10859,15 @@ function TaxonomySection({ taxonomy, collection, entryId, onChange }) {
10554
10859
  const createTermMutation = useMutation({
10555
10860
  mutationFn: (label) => createTerm(taxonomy.name, {
10556
10861
  slug: slugify(label),
10557
- label
10862
+ label,
10863
+ ...entryLocale ? { locale: entryLocale } : {}
10558
10864
  }),
10559
10865
  onSuccess: (newTerm) => {
10560
- queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomy.name] });
10866
+ queryClient.invalidateQueries({ queryKey: [
10867
+ "taxonomy-terms",
10868
+ taxonomy.name,
10869
+ entryLocale
10870
+ ] });
10561
10871
  const newSelected = new Set(selectedIds);
10562
10872
  newSelected.add(newTerm.id);
10563
10873
  setSelectedIds(newSelected);
@@ -10677,7 +10987,7 @@ function TaxonomySection({ taxonomy, collection, entryId, onChange }) {
10677
10987
  /**
10678
10988
  * Main TaxonomySidebar component
10679
10989
  */
10680
- function TaxonomySidebar({ collection, entryId, onChange }) {
10990
+ function TaxonomySidebar({ collection, entryId, entryLocale, onChange }) {
10681
10991
  const { _: _t3 } = useLingui();
10682
10992
  const { data: taxonomies = [] } = useQuery({
10683
10993
  queryKey: ["taxonomy-defs"],
@@ -10699,6 +11009,7 @@ function TaxonomySidebar({ collection, entryId, onChange }) {
10699
11009
  taxonomy,
10700
11010
  collection,
10701
11011
  entryId,
11012
+ entryLocale,
10702
11013
  onChange: (termIds) => onChange?.(taxonomy.name, termIds)
10703
11014
  }, taxonomy.name))
10704
11015
  })] })
@@ -11499,6 +11810,7 @@ function ContentEditor({ collection, collectionLabel, item, fields, isNew, entry
11499
11810
  }), /* @__PURE__ */ jsx(BylineCreditsEditor, {
11500
11811
  credits: activeBylines,
11501
11812
  bylines: availableBylines ?? [],
11813
+ selectedBylineDetails: item?.bylines?.map((entry) => entry.byline),
11502
11814
  bylinesLoaded: availableBylinesLoaded,
11503
11815
  onChange: handleBylinesChange,
11504
11816
  onQuickCreate: onQuickCreateByline,
@@ -11528,7 +11840,8 @@ function ContentEditor({ collection, collectionLabel, item, fields, isNew, entry
11528
11840
  className: "p-4 border-t",
11529
11841
  children: /* @__PURE__ */ jsx(TaxonomySidebar, {
11530
11842
  collection,
11531
- entryId: item.id
11843
+ entryId: item.id,
11844
+ entryLocale: item.locale ?? entryLocale
11532
11845
  })
11533
11846
  }),
11534
11847
  hasSeo && !isNew && onSeoChange && /* @__PURE__ */ jsxs("div", {
@@ -12161,9 +12474,10 @@ function FileFieldRenderer({ id, label, value, onChange, required, allowedMimeTy
12161
12474
  ]
12162
12475
  });
12163
12476
  }
12164
- function BylineCreditsEditor({ credits, bylines, onChange, onQuickCreate, onQuickEdit, entryLocale, i18n, bylinesLoaded = true }) {
12477
+ function BylineCreditsEditor({ credits, bylines, selectedBylineDetails, onChange, onQuickCreate, onQuickEdit, entryLocale, i18n, bylinesLoaded = true }) {
12165
12478
  const { _: _t7 } = useLingui();
12166
- const [selectedBylineId, setSelectedBylineId] = React$1.useState("");
12479
+ const [search, setSearch] = React$1.useState("");
12480
+ const debouncedSearch = useDebouncedValue$1(search, 300);
12167
12481
  const [quickName, setQuickName] = React$1.useState("");
12168
12482
  const [quickSlug, setQuickSlug] = React$1.useState("");
12169
12483
  const [quickError, setQuickError] = React$1.useState(null);
@@ -12173,8 +12487,44 @@ function BylineCreditsEditor({ credits, bylines, onChange, onQuickCreate, onQuic
12173
12487
  const [editSlug, setEditSlug] = React$1.useState("");
12174
12488
  const [editError, setEditError] = React$1.useState(null);
12175
12489
  const [isEditing, setIsEditing] = React$1.useState(false);
12176
- const bylineMap = React$1.useMemo(() => new Map(bylines.map((b) => [b.id, b])), [bylines]);
12177
- const availableToAdd = bylines.filter((b) => !credits.some((c) => c.bylineId === b.id));
12490
+ const trimmedSearch = debouncedSearch.trim();
12491
+ const searchEnabled = trimmedSearch.length > 0;
12492
+ const searchResults = useQuery({
12493
+ queryKey: [
12494
+ "bylines",
12495
+ "credit-picker",
12496
+ entryLocale ?? null,
12497
+ trimmedSearch
12498
+ ],
12499
+ queryFn: () => fetchBylines({
12500
+ search: trimmedSearch,
12501
+ locale: entryLocale ?? void 0,
12502
+ limit: 20
12503
+ }),
12504
+ enabled: searchEnabled,
12505
+ placeholderData: keepPreviousData
12506
+ });
12507
+ const resultPool = searchEnabled ? searchResults.data?.items ?? [] : bylines;
12508
+ const hasMoreResults = searchEnabled ? !!searchResults.data?.nextCursor : bylines.length >= 100;
12509
+ const bylineMap = React$1.useMemo(() => {
12510
+ const map = /* @__PURE__ */ new Map();
12511
+ for (const b of selectedBylineDetails ?? []) map.set(b.id, b);
12512
+ for (const b of bylines) map.set(b.id, b);
12513
+ for (const b of searchResults.data?.items ?? []) map.set(b.id, b);
12514
+ return map;
12515
+ }, [
12516
+ selectedBylineDetails,
12517
+ bylines,
12518
+ searchResults.data?.items
12519
+ ]);
12520
+ const availableToAdd = resultPool.filter((b) => !credits.some((c) => c.bylineId === b.id));
12521
+ const addByline = (bylineId) => {
12522
+ if (credits.some((c) => c.bylineId === bylineId)) return;
12523
+ onChange([...credits, {
12524
+ bylineId,
12525
+ roleLabel: null
12526
+ }]);
12527
+ };
12178
12528
  const move = (index, direction) => {
12179
12529
  const target = index + direction;
12180
12530
  if (target < 0 || target >= credits.length) return;
@@ -12226,39 +12576,70 @@ function BylineCreditsEditor({ credits, bylines, onChange, onQuickCreate, onQuic
12226
12576
  })]
12227
12577
  }),
12228
12578
  /* @__PURE__ */ jsxs("div", {
12229
- className: "flex gap-2",
12230
- children: [/* @__PURE__ */ jsx(Select, {
12231
- value: selectedBylineId,
12232
- onValueChange: (v) => setSelectedBylineId(v ?? ""),
12233
- items: {
12234
- "": _t7({
12235
- id: "r5zcIE",
12236
- message: "Select byline..."
12579
+ className: "space-y-2",
12580
+ children: [
12581
+ /* @__PURE__ */ jsx(Input, {
12582
+ value: search,
12583
+ onChange: (e) => setSearch(e.target.value),
12584
+ placeholder: _t7({
12585
+ id: "96Um6i",
12586
+ message: "Search bylines to add..."
12237
12587
  }),
12238
- ...Object.fromEntries(availableToAdd.map((b) => [b.id, b.displayName]))
12239
- },
12240
- "aria-label": _t7({
12241
- id: "r4Xk7n",
12242
- message: "Select byline"
12588
+ "aria-label": _t7({
12589
+ id: "4L2LjM",
12590
+ message: "Search bylines"
12591
+ })
12243
12592
  }),
12244
- className: "w-full"
12245
- }), /* @__PURE__ */ jsx(Button, {
12246
- type: "button",
12247
- variant: "secondary",
12248
- onClick: () => {
12249
- if (!selectedBylineId) return;
12250
- onChange([...credits, {
12251
- bylineId: selectedBylineId,
12252
- roleLabel: null
12253
- }]);
12254
- setSelectedBylineId("");
12255
- },
12256
- disabled: !selectedBylineId,
12257
- children: _t7({
12258
- id: "m16xKo",
12259
- message: "Add"
12593
+ searchEnabled && searchResults.isLoading ? /* @__PURE__ */ jsx("p", {
12594
+ className: "text-sm text-kumo-subtle",
12595
+ children: _t7({
12596
+ id: "Du+zn+",
12597
+ message: "Searching..."
12598
+ })
12599
+ }) : availableToAdd.length > 0 ? /* @__PURE__ */ jsx("ul", {
12600
+ className: "max-h-48 divide-y overflow-y-auto rounded border",
12601
+ children: availableToAdd.map((b) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
12602
+ type: "button",
12603
+ className: "flex w-full items-center justify-between gap-2 p-2 text-start hover:bg-kumo-tint",
12604
+ onClick: () => addByline(b.id),
12605
+ children: [/* @__PURE__ */ jsxs("span", {
12606
+ className: "min-w-0",
12607
+ children: [/* @__PURE__ */ jsx("span", {
12608
+ className: "block truncate text-sm font-medium",
12609
+ children: b.displayName
12610
+ }), /* @__PURE__ */ jsx("span", {
12611
+ className: "block truncate text-xs text-kumo-subtle",
12612
+ children: b.slug
12613
+ })]
12614
+ }), /* @__PURE__ */ jsx("span", {
12615
+ className: "text-xs text-kumo-subtle",
12616
+ children: _t7({
12617
+ id: "m16xKo",
12618
+ message: "Add"
12619
+ })
12620
+ })]
12621
+ }) }, b.id))
12622
+ }) : searchEnabled && searchResults.isError ? /* @__PURE__ */ jsx("p", {
12623
+ className: "text-sm text-kumo-danger",
12624
+ children: _t7({
12625
+ id: "617r+T",
12626
+ message: "Couldn't search bylines. Please try again."
12627
+ })
12628
+ }) : searchEnabled ? /* @__PURE__ */ jsx("p", {
12629
+ className: "text-sm text-kumo-subtle",
12630
+ children: _t7({
12631
+ id: "ncgkI9",
12632
+ message: "No matching bylines."
12633
+ })
12634
+ }) : null,
12635
+ hasMoreResults && /* @__PURE__ */ jsx("p", {
12636
+ className: "text-xs text-kumo-subtle",
12637
+ children: _t7({
12638
+ id: "7Jpj2/",
12639
+ message: "Keep typing to narrow down more bylines."
12640
+ })
12260
12641
  })
12261
- })]
12642
+ ]
12262
12643
  }),
12263
12644
  credits.length > 0 ? /* @__PURE__ */ jsx("div", {
12264
12645
  className: "space-y-2",
@@ -12637,33 +13018,44 @@ function getItemTitle$1(item) {
12637
13018
  /**
12638
13019
  * Content list view with table display and trash tab
12639
13020
  */
12640
- function ContentList({ collection, collectionLabel, items, trashedItems = [], isLoading, isTrashedLoading, onDelete, onDuplicate, onRestore, onPermanentDelete, onLoadMore, onLoadMoreTrashed, hasMore, hasMoreTrashed, trashedCount = 0, i18n, activeLocale, onLocaleChange, urlPattern, sort, onSortChange, total }) {
13021
+ function ContentList({ collection, collectionLabel, items, trashedItems = [], isLoading, isTrashedLoading, onDelete, onDuplicate, onRestore, onPermanentDelete, onLoadMore, onLoadMoreTrashed, hasMore, hasMoreTrashed, trashedCount = 0, i18n, activeLocale, onLocaleChange, urlPattern, sort, onSortChange, total, onSearchChange }) {
12641
13022
  const { _: _t } = useLingui();
12642
13023
  const [activeTab, setActiveTab] = React$1.useState("all");
12643
13024
  const [searchQuery, setSearchQuery] = React$1.useState("");
12644
13025
  const [page, setPage] = React$1.useState(0);
13026
+ const serverSearch = !!onSearchChange;
13027
+ const debouncedSearch = useDebouncedValue$1(searchQuery, 300);
13028
+ React$1.useEffect(() => {
13029
+ if (onSearchChange) onSearchChange(debouncedSearch.trim());
13030
+ }, [debouncedSearch, onSearchChange]);
12645
13031
  const handleSearchChange = (e) => {
12646
13032
  setSearchQuery(e.target.value);
12647
13033
  setPage(0);
12648
13034
  };
12649
13035
  const filteredItems = React$1.useMemo(() => {
12650
- if (!searchQuery) return items;
13036
+ if (serverSearch || !searchQuery) return items;
12651
13037
  const query = searchQuery.toLowerCase();
12652
13038
  return items.filter((item) => getItemTitle$1(item).toLowerCase().includes(query));
12653
- }, [items, searchQuery]);
12654
- const effectiveTotal = typeof total === "number" && !searchQuery ? total : filteredItems.length;
13039
+ }, [
13040
+ items,
13041
+ searchQuery,
13042
+ serverSearch
13043
+ ]);
13044
+ const activeSearch = serverSearch ? debouncedSearch.trim() : searchQuery;
13045
+ const effectiveTotal = typeof total === "number" && (serverSearch || !searchQuery) ? total : filteredItems.length;
12655
13046
  const totalPages = Math.max(1, Math.ceil(effectiveTotal / PAGE_SIZE));
12656
13047
  const clampedPage = Math.min(page, totalPages - 1);
12657
13048
  const paginatedItems = filteredItems.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE);
12658
13049
  React$1.useEffect(() => {
12659
- if (!hasMore || !onLoadMore || searchQuery) return;
13050
+ if (!hasMore || !onLoadMore || !serverSearch && searchQuery) return;
12660
13051
  if (clampedPage >= Math.ceil(filteredItems.length / PAGE_SIZE) - 1) onLoadMore();
12661
13052
  }, [
12662
13053
  clampedPage,
12663
13054
  filteredItems.length,
12664
13055
  hasMore,
12665
13056
  onLoadMore,
12666
- searchQuery
13057
+ searchQuery,
13058
+ serverSearch
12667
13059
  ]);
12668
13060
  return /* @__PURE__ */ jsxs("div", {
12669
13061
  className: "space-y-4",
@@ -12693,7 +13085,7 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12693
13085
  })
12694
13086
  })]
12695
13087
  }),
12696
- items.length > 0 && /* @__PURE__ */ jsxs("div", {
13088
+ (serverSearch || items.length > 0) && /* @__PURE__ */ jsxs("div", {
12697
13089
  className: "relative max-w-sm",
12698
13090
  children: [/* @__PURE__ */ jsx(MagnifyingGlass, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" }), /* @__PURE__ */ jsx(Input, {
12699
13091
  type: "search",
@@ -12810,10 +13202,14 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12810
13202
  message: "Loading..."
12811
13203
  })]
12812
13204
  })
12813
- }) }) : items.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsxs("td", {
13205
+ }) }) : items.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
12814
13206
  colSpan: i18n ? 5 : 4,
12815
13207
  className: "px-4 py-8 text-center text-kumo-subtle",
12816
- children: [
13208
+ children: activeSearch ? _t({
13209
+ id: "QpOp1w",
13210
+ message: "No results for \"{activeSearch}\"",
13211
+ values: { activeSearch }
13212
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
12817
13213
  _t({
12818
13214
  id: "yHHMoK",
12819
13215
  message: "No {0} yet.",
@@ -12830,14 +13226,14 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12830
13226
  message: "Create your first one"
12831
13227
  })
12832
13228
  })
12833
- ]
13229
+ ] })
12834
13230
  }) }) : paginatedItems.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
12835
13231
  colSpan: i18n ? 5 : 4,
12836
13232
  className: "px-4 py-8 text-center text-kumo-subtle",
12837
13233
  children: _t({
12838
- id: "huTRDC",
12839
- message: "No results for \"{searchQuery}\"",
12840
- values: { searchQuery }
13234
+ id: "QpOp1w",
13235
+ message: "No results for \"{activeSearch}\"",
13236
+ values: { activeSearch }
12841
13237
  })
12842
13238
  }) }) : paginatedItems.map((item) => /* @__PURE__ */ jsx(ContentListItem, {
12843
13239
  item,
@@ -12855,10 +13251,11 @@ function ContentList({ collection, collectionLabel, items, trashedItems = [], is
12855
13251
  children: [/* @__PURE__ */ jsx("span", {
12856
13252
  className: "text-sm text-kumo-subtle",
12857
13253
  children: renderItemCount({
12858
- searchQuery,
13254
+ searchQuery: activeSearch,
12859
13255
  filteredCount: filteredItems.length,
12860
13256
  total,
12861
- hasMore
13257
+ hasMore,
13258
+ serverSearch
12862
13259
  })
12863
13260
  }), /* @__PURE__ */ jsxs("div", {
12864
13261
  className: "flex items-center gap-2",
@@ -13041,21 +13438,26 @@ function SortableTh({ field, sort, onSortChange, label }) {
13041
13438
  }
13042
13439
  /**
13043
13440
  * Render the row-count line above pagination. The rules are:
13044
- * - A search query always wins — say how many matches there are.
13441
+ * - A search query always wins — say how many matches there are. In
13442
+ * server-search mode the server reports the full match count via `total`;
13443
+ * `filteredCount` is only the loaded page, so it would undercount.
13045
13444
  * - When the server reported a total, use it (no `+` suffix needed —
13046
13445
  * we know the count).
13047
13446
  * - Otherwise fall back to the pre-refactor behavior: loaded count,
13048
13447
  * with `+` when there are more pages the user hasn't fetched yet.
13049
13448
  */
13050
- function renderItemCount({ searchQuery, filteredCount, total, hasMore }) {
13051
- if (searchQuery) return i18n._({
13052
- id: "WqhW35",
13053
- message: "{filteredCount, plural, one {# item matching \"{searchQuery}\"} other {# items matching \"{searchQuery}\"}}",
13054
- values: {
13055
- filteredCount,
13056
- searchQuery
13057
- }
13058
- });
13449
+ function renderItemCount({ searchQuery, filteredCount, total, hasMore, serverSearch }) {
13450
+ if (searchQuery) {
13451
+ const matchCount = serverSearch && typeof total === "number" ? total : filteredCount;
13452
+ return i18n._({
13453
+ id: "UFFm2d",
13454
+ message: "{matchCount, plural, one {# item matching \"{searchQuery}\"} other {# items matching \"{searchQuery}\"}}",
13455
+ values: {
13456
+ matchCount,
13457
+ searchQuery
13458
+ }
13459
+ });
13460
+ }
13059
13461
  if (typeof total === "number") return i18n._({
13060
13462
  id: "Q/6//K",
13061
13463
  message: "{total, plural, one {# item} other {# items}}",
@@ -13593,8 +13995,8 @@ function AllowedTypesEditor({ value, onChange }) {
13593
13995
 
13594
13996
  //#endregion
13595
13997
  //#region src/components/FieldEditor.tsx
13596
- const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g;
13597
- const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g;
13998
+ const SLUG_INVALID_CHARS_REGEX$1 = /[^a-z0-9]+/g;
13999
+ const SLUG_LEADING_TRAILING_REGEX$1 = /^_|_$/g;
13598
14000
  function getInitialFormState(field) {
13599
14001
  if (field) return {
13600
14002
  step: "config",
@@ -13846,7 +14248,7 @@ function FieldEditor({ open, onOpenChange, field, onSave, isSaving }) {
13846
14248
  ];
13847
14249
  const handleLabelChange = (value) => {
13848
14250
  setField("label", value);
13849
- if (!field) setField("slug", value.toLowerCase().replace(SLUG_INVALID_CHARS_REGEX, "_").replace(SLUG_LEADING_TRAILING_REGEX, ""));
14251
+ if (!field) setField("slug", value.toLowerCase().replace(SLUG_INVALID_CHARS_REGEX$1, "_").replace(SLUG_LEADING_TRAILING_REGEX$1, ""));
13850
14252
  };
13851
14253
  const handleTypeSelect = (type) => {
13852
14254
  setFormState((prev) => ({
@@ -14205,7 +14607,7 @@ function FieldEditor({ open, onOpenChange, field, onSave, isSaving }) {
14205
14607
  updated[i] = {
14206
14608
  ...sf,
14207
14609
  label: e.target.value,
14208
- slug: e.target.value.toLowerCase().replace(SLUG_INVALID_CHARS_REGEX, "_").replace(SLUG_LEADING_TRAILING_REGEX, "")
14610
+ slug: e.target.value.toLowerCase().replace(SLUG_INVALID_CHARS_REGEX$1, "_").replace(SLUG_LEADING_TRAILING_REGEX$1, "")
14209
14611
  };
14210
14612
  setFormState((prev) => ({
14211
14613
  ...prev,
@@ -14992,7 +15394,7 @@ function ContentTypeEditor({ collection, isNew, isSaving, onSave, onAddField, on
14992
15394
  strategy: verticalListSortingStrategy,
14993
15395
  children: /* @__PURE__ */ jsx("div", {
14994
15396
  className: "divide-y",
14995
- children: fields.map((field) => /* @__PURE__ */ jsx(FieldRow, {
15397
+ children: fields.map((field) => /* @__PURE__ */ jsx(FieldRow$1, {
14996
15398
  field,
14997
15399
  isFromCode,
14998
15400
  onEdit: () => handleEditField(field),
@@ -15044,7 +15446,7 @@ function ContentTypeEditor({ collection, isNew, isSaving, onSave, onAddField, on
15044
15446
  ]
15045
15447
  });
15046
15448
  }
15047
- function FieldRow({ field, isFromCode, onEdit, onDelete }) {
15449
+ function FieldRow$1({ field, isFromCode, onEdit, onDelete }) {
15048
15450
  const { _: _t2 } = useLingui();
15049
15451
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
15050
15452
  id: field.id,
@@ -20221,15 +20623,34 @@ function formatDate$1(isoString) {
20221
20623
 
20222
20624
  //#endregion
20223
20625
  //#region src/components/MediaLibrary.tsx
20626
+ /** Maps a coarse type-filter choice to the media list's `mimeType` filter. */
20627
+ function mimeForTypeFilter(value) {
20628
+ switch (value) {
20629
+ case "image": return "image/";
20630
+ case "video": return "video/";
20631
+ case "audio": return "audio/";
20632
+ case "document": return ["application/", "text/"];
20633
+ default: return;
20634
+ }
20635
+ }
20224
20636
  /**
20225
20637
  * Media library component with upload, provider tabs, and grid view
20226
20638
  */
20227
- function MediaLibrary({ items = [], isLoading, onUpload, onDelete, onItemUpdated, hasMore, onLoadMore }) {
20639
+ function MediaLibrary({ items = [], isLoading, onUpload, onDelete, onItemUpdated, hasMore, onLoadMore, onLocalSearchChange, onLocalMimeFilterChange }) {
20228
20640
  const { _: _t } = useLingui();
20229
20641
  const [viewMode, setViewMode] = React$1.useState("grid");
20230
20642
  const [selectedItem, setSelectedItem] = React$1.useState(null);
20231
20643
  const [activeProvider, setActiveProvider] = React$1.useState("local");
20232
20644
  const [searchQuery, setSearchQuery] = React$1.useState("");
20645
+ const [localTypeFilter, setLocalTypeFilter] = React$1.useState("all");
20646
+ const debouncedSearch = useDebouncedValue$1(searchQuery, 300);
20647
+ React$1.useEffect(() => {
20648
+ if (activeProvider === "local" && onLocalSearchChange) onLocalSearchChange(debouncedSearch.trim());
20649
+ }, [
20650
+ debouncedSearch,
20651
+ activeProvider,
20652
+ onLocalSearchChange
20653
+ ]);
20233
20654
  const [uploadState, setUploadState] = React$1.useState({ status: "idle" });
20234
20655
  const fileInputRef = React$1.useRef(null);
20235
20656
  const [loadedDimensions, setLoadedDimensions] = React$1.useState({});
@@ -20549,17 +20970,61 @@ function MediaLibrary({ items = [], isLoading, onUpload, onDelete, onItemUpdated
20549
20970
  ]
20550
20971
  })]
20551
20972
  }),
20552
- canSearch && /* @__PURE__ */ jsxs("div", {
20553
- className: "relative max-w-sm",
20554
- children: [/* @__PURE__ */ jsx(MagnifyingGlass, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" }), /* @__PURE__ */ jsx(Input, {
20555
- type: "search",
20556
- placeholder: _t({
20557
- id: "YIix5Y",
20558
- message: "Search..."
20559
- }),
20560
- value: searchQuery,
20561
- onChange: (e) => setSearchQuery(e.target.value),
20562
- className: "ps-9"
20973
+ (canSearch || activeProvider === "local") && /* @__PURE__ */ jsxs("div", {
20974
+ className: "flex flex-wrap items-center gap-3",
20975
+ children: [/* @__PURE__ */ jsxs("div", {
20976
+ className: "relative max-w-sm flex-1",
20977
+ children: [/* @__PURE__ */ jsx(MagnifyingGlass, { className: "absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" }), /* @__PURE__ */ jsx(Input, {
20978
+ type: "search",
20979
+ placeholder: activeProvider === "local" ? _t({
20980
+ id: "Fwo2VZ",
20981
+ message: "Search by filename..."
20982
+ }) : _t({
20983
+ id: "YIix5Y",
20984
+ message: "Search..."
20985
+ }),
20986
+ "aria-label": _t({
20987
+ id: "GzM+mT",
20988
+ message: "Search media"
20989
+ }),
20990
+ value: searchQuery,
20991
+ onChange: (e) => setSearchQuery(e.target.value),
20992
+ maxLength: MEDIA_SEARCH_MAX_LENGTH,
20993
+ className: "ps-9"
20994
+ })]
20995
+ }), activeProvider === "local" && /* @__PURE__ */ jsx(Select, {
20996
+ value: localTypeFilter,
20997
+ onValueChange: (v) => {
20998
+ const next = v ?? "all";
20999
+ setLocalTypeFilter(next);
21000
+ onLocalMimeFilterChange?.(mimeForTypeFilter(next));
21001
+ },
21002
+ items: {
21003
+ all: _t({
21004
+ id: "h6zvpR",
21005
+ message: "All types"
21006
+ }),
21007
+ image: _t({
21008
+ id: "an5hVd",
21009
+ message: "Images"
21010
+ }),
21011
+ video: _t({
21012
+ id: "vSJd18",
21013
+ message: "Video"
21014
+ }),
21015
+ audio: _t({
21016
+ id: "/rTz0M",
21017
+ message: "Audio"
21018
+ }),
21019
+ document: _t({
21020
+ id: "E/muDO",
21021
+ message: "Documents"
21022
+ })
21023
+ },
21024
+ "aria-label": _t({
21025
+ id: "mP+cvB",
21026
+ message: "Filter by type"
21027
+ })
20563
21028
  })]
20564
21029
  }),
20565
21030
  currentLoading && currentItems.length === 0 && currentProviderItems.length === 0 ? /* @__PURE__ */ jsx("div", {
@@ -21371,8 +21836,9 @@ function MenuEditor() {
21371
21836
  });
21372
21837
  };
21373
21838
  const handleAddContent = (item) => {
21839
+ const type = item.collection === "pages" ? "page" : item.collection === "posts" ? "post" : "collection";
21374
21840
  createMutation.mutate({
21375
- type: item.collection,
21841
+ type,
21376
21842
  label: item.title,
21377
21843
  referenceCollection: item.collection,
21378
21844
  referenceId: item.id
@@ -28589,7 +29055,7 @@ function SetupWizard() {
28589
29055
  * Quick navigation and search across the admin interface.
28590
29056
  * Opens with Cmd+K (Mac) or Ctrl+K (Windows/Linux).
28591
29057
  */
28592
- const ROLE_ADMIN$2 = 50;
29058
+ const ROLE_ADMIN$3 = 50;
28593
29059
  const ROLE_EDITOR$2 = 40;
28594
29060
  const ROUTE_PARAM_REGEX = /\$(\w+)/g;
28595
29061
  const SEARCH_DEBOUNCE_MS = 300;
@@ -28691,7 +29157,7 @@ function buildNavItems(manifest, userRole) {
28691
29157
  },
28692
29158
  to: "/content-types",
28693
29159
  icon: Database,
28694
- minRole: ROLE_ADMIN$2,
29160
+ minRole: ROLE_ADMIN$3,
28695
29161
  keywords: ["schema", "collections"]
28696
29162
  }, {
28697
29163
  id: "categories",
@@ -28723,7 +29189,7 @@ function buildNavItems(manifest, userRole) {
28723
29189
  },
28724
29190
  to: "/users",
28725
29191
  icon: Users,
28726
- minRole: ROLE_ADMIN$2,
29192
+ minRole: ROLE_ADMIN$3,
28727
29193
  keywords: ["accounts", "team"]
28728
29194
  }, {
28729
29195
  id: "plugins",
@@ -28733,7 +29199,7 @@ function buildNavItems(manifest, userRole) {
28733
29199
  },
28734
29200
  to: "/plugins-manager",
28735
29201
  icon: PuzzlePiece,
28736
- minRole: ROLE_ADMIN$2,
29202
+ minRole: ROLE_ADMIN$3,
28737
29203
  keywords: ["extensions", "add-ons"]
28738
29204
  }, {
28739
29205
  id: "import",
@@ -28743,7 +29209,7 @@ function buildNavItems(manifest, userRole) {
28743
29209
  },
28744
29210
  to: "/import/wordpress",
28745
29211
  icon: Upload,
28746
- minRole: ROLE_ADMIN$2,
29212
+ minRole: ROLE_ADMIN$3,
28747
29213
  keywords: ["wordpress", "migrate"]
28748
29214
  }, {
28749
29215
  id: "settings",
@@ -28753,7 +29219,7 @@ function buildNavItems(manifest, userRole) {
28753
29219
  },
28754
29220
  to: "/settings",
28755
29221
  icon: Gear,
28756
- minRole: ROLE_ADMIN$2,
29222
+ minRole: ROLE_ADMIN$3,
28757
29223
  keywords: ["configuration", "preferences"]
28758
29224
  }, {
28759
29225
  id: "security",
@@ -28763,7 +29229,7 @@ function buildNavItems(manifest, userRole) {
28763
29229
  },
28764
29230
  to: "/settings/security",
28765
29231
  icon: Gear,
28766
- minRole: ROLE_ADMIN$2,
29232
+ minRole: ROLE_ADMIN$3,
28767
29233
  keywords: ["passkeys", "authentication"]
28768
29234
  });
28769
29235
  for (const [pluginId, config] of Object.entries(manifest.plugins)) {
@@ -28953,9 +29419,35 @@ function AdminCommandPalette({ manifest }) {
28953
29419
 
28954
29420
  //#endregion
28955
29421
  //#region src/components/Sidebar.tsx
28956
- const ROLE_ADMIN$1 = 50;
29422
+ const ROLE_ADMIN$2 = 50;
28957
29423
  const ROLE_EDITOR$1 = 40;
28958
29424
  /**
29425
+ * Static invariants for nav entries that have AC-level visibility
29426
+ * requirements (Phase 5 of Discussion #1174: "Admin sees the 'Byline
29427
+ * Schema' entry; Editor does not").
29428
+ *
29429
+ * Exported as plain data so a unit test can assert the route + role
29430
+ * pairing without mounting Kumo's Sidebar primitive — which portals
29431
+ * its rendered content to `document.body` and applies collapse-state
29432
+ * CSS (`display:none` on labels at narrow viewports), making
29433
+ * full-DOM tests of role filtering brittle. The runtime `adminItems`
29434
+ * array below references these constants directly so the test
29435
+ * effectively guards the production list.
29436
+ */
29437
+ const BYLINE_SCHEMA_NAV_ITEM = {
29438
+ to: "/byline-schema",
29439
+ minRole: ROLE_ADMIN$2
29440
+ };
29441
+ /**
29442
+ * Filter a nav-items list by user role. Pure function — exported so
29443
+ * tests can verify the role gate without rendering the sidebar. An
29444
+ * item passes when it has no `minRole` (public) or the user is at
29445
+ * least the required level.
29446
+ */
29447
+ function filterNavItemsByRole(items, userRole) {
29448
+ return items.filter((item) => !item.minRole || userRole >= item.minRole);
29449
+ }
29450
+ /**
28959
29451
  * Navigation item rendered as a TanStack Router <Link> inside kumo's
28960
29452
  * Sidebar.MenuItem. Styled to match kumo MenuButton appearance.
28961
29453
  * This approach guarantees client-side navigation works correctly.
@@ -29060,7 +29552,7 @@ function SidebarNav({ manifest }) {
29060
29552
  message: "Redirects"
29061
29553
  }),
29062
29554
  icon: ArrowsLeftRight,
29063
- minRole: ROLE_ADMIN$1
29555
+ minRole: ROLE_ADMIN$2
29064
29556
  },
29065
29557
  {
29066
29558
  to: "/widgets",
@@ -29105,7 +29597,7 @@ function SidebarNav({ manifest }) {
29105
29597
  message: "Content Types"
29106
29598
  }),
29107
29599
  icon: Database,
29108
- minRole: ROLE_ADMIN$1
29600
+ minRole: ROLE_ADMIN$2
29109
29601
  },
29110
29602
  {
29111
29603
  to: "/users",
@@ -29114,7 +29606,7 @@ function SidebarNav({ manifest }) {
29114
29606
  message: "Users"
29115
29607
  }),
29116
29608
  icon: Users,
29117
- minRole: ROLE_ADMIN$1
29609
+ minRole: ROLE_ADMIN$2
29118
29610
  },
29119
29611
  {
29120
29612
  to: "/plugins-manager",
@@ -29123,7 +29615,7 @@ function SidebarNav({ manifest }) {
29123
29615
  message: "Plugins"
29124
29616
  }),
29125
29617
  icon: PuzzlePiece,
29126
- minRole: ROLE_ADMIN$1
29618
+ minRole: ROLE_ADMIN$2
29127
29619
  }
29128
29620
  ];
29129
29621
  if (manifest.registry) adminItems.push({
@@ -29133,7 +29625,7 @@ function SidebarNav({ manifest }) {
29133
29625
  message: "Registry"
29134
29626
  }),
29135
29627
  icon: Storefront,
29136
- minRole: ROLE_ADMIN$1
29628
+ minRole: ROLE_ADMIN$2
29137
29629
  });
29138
29630
  else if (manifest.marketplace) adminItems.push({
29139
29631
  to: "/plugins/marketplace",
@@ -29142,7 +29634,7 @@ function SidebarNav({ manifest }) {
29142
29634
  message: "Marketplace"
29143
29635
  }),
29144
29636
  icon: Storefront,
29145
- minRole: ROLE_ADMIN$1
29637
+ minRole: ROLE_ADMIN$2
29146
29638
  });
29147
29639
  if (manifest.marketplace) adminItems.push({
29148
29640
  to: "/themes/marketplace",
@@ -29151,7 +29643,7 @@ function SidebarNav({ manifest }) {
29151
29643
  message: "Themes"
29152
29644
  }),
29153
29645
  icon: Palette,
29154
- minRole: ROLE_ADMIN$1
29646
+ minRole: ROLE_ADMIN$2
29155
29647
  });
29156
29648
  adminItems.push({
29157
29649
  to: "/import/wordpress",
@@ -29160,7 +29652,7 @@ function SidebarNav({ manifest }) {
29160
29652
  message: "Import"
29161
29653
  }),
29162
29654
  icon: Upload,
29163
- minRole: ROLE_ADMIN$1
29655
+ minRole: ROLE_ADMIN$2
29164
29656
  }, {
29165
29657
  to: "/settings",
29166
29658
  label: _t({
@@ -29168,7 +29660,7 @@ function SidebarNav({ manifest }) {
29168
29660
  message: "Settings"
29169
29661
  }),
29170
29662
  icon: Gear,
29171
- minRole: ROLE_ADMIN$1
29663
+ minRole: ROLE_ADMIN$2
29172
29664
  });
29173
29665
  const pluginItems = [];
29174
29666
  for (const [pluginId, config] of Object.entries(manifest.plugins)) {
@@ -29187,11 +29679,10 @@ function SidebarNav({ manifest }) {
29187
29679
  }
29188
29680
  }
29189
29681
  }
29190
- const filterByRole = (items) => items.filter((item) => !item.minRole || userRole >= item.minRole);
29191
- const visibleContent = filterByRole(contentItems);
29192
- const visibleManage = filterByRole(manageItems);
29193
- const visibleAdmin = filterByRole(adminItems);
29194
- const visiblePlugins = filterByRole(pluginItems);
29682
+ const visibleContent = filterNavItemsByRole(contentItems, userRole);
29683
+ const visibleManage = filterNavItemsByRole(manageItems, userRole);
29684
+ const visibleAdmin = filterNavItemsByRole(adminItems, userRole);
29685
+ const visiblePlugins = filterNavItemsByRole(pluginItems, userRole);
29195
29686
  function renderNavItems(items) {
29196
29687
  return items.map((item, index) => {
29197
29688
  return /* @__PURE__ */ jsx(NavMenuLink, {
@@ -29204,6 +29695,9 @@ function SidebarNav({ manifest }) {
29204
29695
  /* Classic dark chrome — override kumo tokens within the sidebar */
29205
29696
  .emdash-sidebar {
29206
29697
  --color-kumo-base: #1d2327;
29698
+ /* Kumo 2.4 paints the surface via bg-(--sidebar-bg) on an inner
29699
+ container, resolved from the wrapper's light --color-kumo-base. */
29700
+ --sidebar-bg: #1d2327;
29207
29701
  --color-kumo-tint: rgba(255,255,255,0.1);
29208
29702
  --color-kumo-line: rgba(255,255,255,0.08);
29209
29703
  --color-kumo-brand: #2271b1;
@@ -35285,6 +35779,858 @@ function AuthorMappingStep({ authorMappings, emdashUsers, onMappingChange, onCon
35285
35779
  });
35286
35780
  }
35287
35781
 
35782
+ //#endregion
35783
+ //#region src/components/BylineFieldEditor.tsx
35784
+ /**
35785
+ * BylineFieldEditor (Phase 5 of Discussion #1174).
35786
+ *
35787
+ * Purpose-built create/edit dialog for byline custom-field definitions.
35788
+ * Constrains the type select to the five v1 types declared by the
35789
+ * `BylineFieldType` union — narrower than the content-field universe —
35790
+ * and exposes the `translatable` toggle (the storage-split flag) that
35791
+ * the content `FieldEditor` doesn't have.
35792
+ *
35793
+ * Single-step form (not the type-picker → config flow used by
35794
+ * `FieldEditor`): with only five types a `<Select>` is faster than a
35795
+ * grid of cards and the dialog stays under ~200 LOC.
35796
+ *
35797
+ * The `translatable` toggle is locked when editing a field that already
35798
+ * has stored values — the server returns `TRANSLATABLE_LOCKED` (409) for
35799
+ * the flip, but disabling the input + showing a help message saves a
35800
+ * round-trip and is friendlier than a toast. Usage is passed in by the
35801
+ * parent (which fetches `getBylineFieldUsage` on edit-open) so this
35802
+ * component stays a pure controlled form.
35803
+ */
35804
+ const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g;
35805
+ const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g;
35806
+ /**
35807
+ * The five v1 byline field types. Kept in lock-step with the server-side
35808
+ * `BYLINE_FIELD_TYPES` constant (`packages/core/src/schema/types.ts`).
35809
+ *
35810
+ * `MessageDescriptor`s are resolved with `t(descriptor)` inside the
35811
+ * component so the strings extract correctly to the Lingui catalog.
35812
+ */
35813
+ const TYPE_OPTIONS = [
35814
+ {
35815
+ value: "string",
35816
+ label: {
35817
+ id: "q13dKg",
35818
+ message: "Short text"
35819
+ }
35820
+ },
35821
+ {
35822
+ value: "text",
35823
+ label: {
35824
+ id: "g+zzQs",
35825
+ message: "Long text"
35826
+ }
35827
+ },
35828
+ {
35829
+ value: "url",
35830
+ label: {
35831
+ id: "IagCbF",
35832
+ message: "URL"
35833
+ }
35834
+ },
35835
+ {
35836
+ value: "boolean",
35837
+ label: {
35838
+ id: "bOwPjT",
35839
+ message: "Boolean"
35840
+ }
35841
+ },
35842
+ {
35843
+ value: "select",
35844
+ label: {
35845
+ id: "rG3WVm",
35846
+ message: "Select"
35847
+ }
35848
+ }
35849
+ ];
35850
+ function initialFormState(field) {
35851
+ if (!field) return {
35852
+ slug: "",
35853
+ label: "",
35854
+ type: "string",
35855
+ required: false,
35856
+ translatable: true,
35857
+ options: ""
35858
+ };
35859
+ return {
35860
+ slug: field.slug,
35861
+ label: field.label,
35862
+ type: field.type,
35863
+ required: field.required,
35864
+ translatable: field.translatable,
35865
+ options: field.validation?.options?.join("\n") ?? ""
35866
+ };
35867
+ }
35868
+ function BylineFieldEditor({ open, onOpenChange, field, usageTotal = 0, onSave, isSaving, error }) {
35869
+ const { _: _t } = useLingui();
35870
+ const isEdit = !!field;
35871
+ const [state, setState] = React$1.useState(() => initialFormState(field));
35872
+ React$1.useEffect(() => {
35873
+ if (open) setState(initialFormState(field));
35874
+ }, [open, field]);
35875
+ const setField = (key, value) => setState((prev) => ({
35876
+ ...prev,
35877
+ [key]: value
35878
+ }));
35879
+ const handleLabelChange = (value) => {
35880
+ setField("label", value);
35881
+ if (!isEdit) setField("slug", value.toLowerCase().replace(SLUG_INVALID_CHARS_REGEX, "_").replace(SLUG_LEADING_TRAILING_REGEX, ""));
35882
+ };
35883
+ const parsedSelectOptions = state.type === "select" ? state.options.split("\n").map((o) => o.trim()).filter(Boolean) : [];
35884
+ const handleSave = () => {
35885
+ if (!state.label.trim()) return;
35886
+ if (!isEdit && !state.slug.trim()) return;
35887
+ if (state.type === "select" && parsedSelectOptions.length === 0) return;
35888
+ const validation = state.type === "select" ? { options: parsedSelectOptions } : null;
35889
+ if (isEdit) onSave({
35890
+ label: state.label,
35891
+ required: state.required,
35892
+ translatable: field && state.translatable !== field.translatable ? state.translatable : void 0,
35893
+ validation
35894
+ });
35895
+ else onSave({
35896
+ slug: state.slug,
35897
+ label: state.label,
35898
+ type: state.type,
35899
+ required: state.required,
35900
+ translatable: state.translatable,
35901
+ validation
35902
+ });
35903
+ };
35904
+ const translatableLocked = isEdit && usageTotal > 0;
35905
+ const canSave = state.label.trim().length > 0 && (isEdit || state.slug.trim().length > 0) && (state.type !== "select" || parsedSelectOptions.length > 0);
35906
+ return /* @__PURE__ */ jsx(Dialog.Root, {
35907
+ open,
35908
+ onOpenChange,
35909
+ children: /* @__PURE__ */ jsxs(Dialog, {
35910
+ className: "p-6 max-w-xl",
35911
+ size: "lg",
35912
+ children: [
35913
+ /* @__PURE__ */ jsxs("div", {
35914
+ className: "flex items-start justify-between gap-4 mb-4",
35915
+ children: [/* @__PURE__ */ jsx(Dialog.Title, {
35916
+ className: "text-lg font-semibold leading-none tracking-tight",
35917
+ children: isEdit ? _t({
35918
+ id: "hcfOYc",
35919
+ message: "Edit byline field"
35920
+ }) : _t({
35921
+ id: "hT2KP5",
35922
+ message: "New byline field"
35923
+ })
35924
+ }), /* @__PURE__ */ jsx(Dialog.Close, {
35925
+ "aria-label": _t({
35926
+ id: "yz7wBu",
35927
+ message: "Close"
35928
+ }),
35929
+ render: (props) => /* @__PURE__ */ jsxs(Button, {
35930
+ ...props,
35931
+ variant: "ghost",
35932
+ shape: "square",
35933
+ "aria-label": _t({
35934
+ id: "yz7wBu",
35935
+ message: "Close"
35936
+ }),
35937
+ className: "absolute end-4 top-4",
35938
+ children: [/* @__PURE__ */ jsx(X, { className: "h-4 w-4" }), /* @__PURE__ */ jsx("span", {
35939
+ className: "sr-only",
35940
+ children: _t({
35941
+ id: "yz7wBu",
35942
+ message: "Close"
35943
+ })
35944
+ })]
35945
+ })
35946
+ })]
35947
+ }),
35948
+ /* @__PURE__ */ jsxs("div", {
35949
+ className: "space-y-5",
35950
+ children: [
35951
+ /* @__PURE__ */ jsxs("div", {
35952
+ className: "grid grid-cols-2 gap-4",
35953
+ children: [/* @__PURE__ */ jsx(Input, {
35954
+ label: _t({
35955
+ id: "87a/t/",
35956
+ message: "Label"
35957
+ }),
35958
+ value: state.label,
35959
+ onChange: (e) => handleLabelChange(e.target.value),
35960
+ placeholder: _t({
35961
+ id: "qMgKul",
35962
+ message: "Job title"
35963
+ })
35964
+ }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Input, {
35965
+ label: _t({
35966
+ id: "L85WcV",
35967
+ message: "Slug"
35968
+ }),
35969
+ value: state.slug,
35970
+ onChange: (e) => setField("slug", e.target.value),
35971
+ placeholder: _t({
35972
+ id: "gddbae",
35973
+ message: "job_title"
35974
+ }),
35975
+ disabled: isEdit
35976
+ }), isEdit && /* @__PURE__ */ jsx("p", {
35977
+ className: "text-xs text-kumo-subtle mt-2",
35978
+ children: _t({
35979
+ id: "5OXS9U",
35980
+ message: "Slugs cannot be changed after the field is created."
35981
+ })
35982
+ })] })]
35983
+ }),
35984
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Select, {
35985
+ label: _t({
35986
+ id: "+zy2Nq",
35987
+ message: "Type"
35988
+ }),
35989
+ value: state.type,
35990
+ onValueChange: (v) => setField("type", v ?? "string"),
35991
+ items: Object.fromEntries(TYPE_OPTIONS.map(({ value, label }) => [value, _t(label)])),
35992
+ disabled: isEdit
35993
+ }), isEdit && /* @__PURE__ */ jsx("p", {
35994
+ className: "text-xs text-kumo-subtle mt-2",
35995
+ children: _t({
35996
+ id: "NF9uuv",
35997
+ message: "Field type cannot be changed after creation."
35998
+ })
35999
+ })] }),
36000
+ /* @__PURE__ */ jsxs("div", {
36001
+ className: "flex flex-wrap items-center gap-6",
36002
+ children: [/* @__PURE__ */ jsx(Switch, {
36003
+ checked: state.required,
36004
+ onCheckedChange: (checked) => setField("required", checked),
36005
+ label: /* @__PURE__ */ jsx("span", {
36006
+ className: "text-sm",
36007
+ children: _t({
36008
+ id: "TMLAx2",
36009
+ message: "Required"
36010
+ })
36011
+ })
36012
+ }), /* @__PURE__ */ jsxs("div", {
36013
+ className: "flex flex-col gap-1",
36014
+ children: [/* @__PURE__ */ jsx(Switch, {
36015
+ checked: state.translatable,
36016
+ onCheckedChange: (checked) => setField("translatable", checked),
36017
+ disabled: translatableLocked,
36018
+ label: /* @__PURE__ */ jsx("span", {
36019
+ className: "text-sm",
36020
+ children: _t({
36021
+ id: "O/SdND",
36022
+ message: "Translatable"
36023
+ })
36024
+ })
36025
+ }), /* @__PURE__ */ jsx("p", {
36026
+ className: "text-xs text-kumo-subtle max-w-xs",
36027
+ children: translatableLocked ? _t({
36028
+ id: "3GPtwD",
36029
+ message: "Locked because this field has stored values. Delete the values (or the field) to change this."
36030
+ }) : state.translatable ? _t({
36031
+ id: "ONAVuM",
36032
+ message: "Stored per locale — each translation of a byline gets its own value."
36033
+ }) : _t({
36034
+ id: "msPOkF",
36035
+ message: "Shared across all translations of the same byline."
36036
+ })
36037
+ })]
36038
+ })]
36039
+ }),
36040
+ state.type === "select" && /* @__PURE__ */ jsx(InputArea, {
36041
+ label: _t({
36042
+ id: "okW6+j",
36043
+ message: "Options (one per line)"
36044
+ }),
36045
+ value: state.options,
36046
+ onChange: (e) => setField("options", e.target.value),
36047
+ placeholder: _t({
36048
+ id: "Y9cJqL",
36049
+ message: "Editor\nReporter\nPhotographer"
36050
+ }),
36051
+ rows: 5
36052
+ }),
36053
+ /* @__PURE__ */ jsx(DialogError, {
36054
+ message: getMutationError(error),
36055
+ className: "mt-1"
36056
+ })
36057
+ ]
36058
+ }),
36059
+ /* @__PURE__ */ jsxs("div", {
36060
+ className: "mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
36061
+ children: [/* @__PURE__ */ jsx(Button, {
36062
+ variant: "outline",
36063
+ onClick: () => onOpenChange(false),
36064
+ disabled: isSaving,
36065
+ children: _t({
36066
+ id: "dEgA5A",
36067
+ message: "Cancel"
36068
+ })
36069
+ }), /* @__PURE__ */ jsx(Button, {
36070
+ onClick: handleSave,
36071
+ disabled: !canSave || isSaving,
36072
+ children: isSaving ? _t({
36073
+ id: "K/F6pa",
36074
+ message: "Saving…"
36075
+ }) : isEdit ? _t({
36076
+ id: "y3aU20",
36077
+ message: "Save changes"
36078
+ }) : _t({
36079
+ id: "A3eIjV",
36080
+ message: "Create field"
36081
+ })
36082
+ })]
36083
+ })
36084
+ ]
36085
+ })
36086
+ });
36087
+ }
36088
+
36089
+ //#endregion
36090
+ //#region src/routes/byline-schema.tsx
36091
+ /**
36092
+ * Byline custom-field schema management (Phase 5 of Discussion #1174).
36093
+ *
36094
+ * Admin can create / edit / delete / reorder byline custom-field
36095
+ * definitions. Modelled on the content-type management UX but uses the
36096
+ * purpose-built `BylineFieldEditor` (5-type subset + `translatable`
36097
+ * toggle).
36098
+ *
36099
+ * Reorder uses ↑/↓ buttons per row rather than drag-and-drop — the v1
36100
+ * registry caps the field set at a small number (admins typically
36101
+ * register 2–5 custom fields per site), accessibility is better without
36102
+ * pulling in a DnD dependency, and screen-reader users get usable
36103
+ * keyboard semantics for free. The reorder API takes the full slug list
36104
+ * in the desired order; clicking ↑ swaps the row with its neighbour and
36105
+ * fires the mutation.
36106
+ *
36107
+ * Permission gate: `ROLE_ADMIN`. The route is also gated at the sidebar
36108
+ * level (`minRole: ROLE_ADMIN`), but a manually-typed URL still hits
36109
+ * this page — so the in-component check is the source of truth.
36110
+ */
36111
+ const ROLE_ADMIN$1 = 50;
36112
+ const QUERY_KEY = ["byline-fields"];
36113
+ function BylineSchemaPage() {
36114
+ const { _: _t } = useLingui();
36115
+ const queryClient = useQueryClient();
36116
+ const toastManager = Toast.useToastManager();
36117
+ const { data: currentUser, isLoading: userLoading } = useCurrentUser();
36118
+ const [editingField, setEditingField] = React$1.useState(null);
36119
+ const [creating, setCreating] = React$1.useState(false);
36120
+ const [deletingField, setDeletingField] = React$1.useState(null);
36121
+ const fieldsQuery = useQuery({
36122
+ queryKey: QUERY_KEY,
36123
+ queryFn: listBylineFields,
36124
+ enabled: !!currentUser && currentUser.role >= ROLE_ADMIN$1
36125
+ });
36126
+ const usageQuery = useQuery({
36127
+ queryKey: [
36128
+ "byline-schema",
36129
+ "usage",
36130
+ editingField?.slug ?? null
36131
+ ],
36132
+ queryFn: () => editingField ? getBylineFieldUsage(editingField.slug) : Promise.resolve(null),
36133
+ enabled: !!editingField
36134
+ });
36135
+ const deleteUsageQuery = useQuery({
36136
+ queryKey: [
36137
+ "byline-schema",
36138
+ "usage",
36139
+ deletingField?.slug ?? null
36140
+ ],
36141
+ queryFn: () => deletingField ? getBylineFieldUsage(deletingField.slug) : Promise.resolve(null),
36142
+ enabled: !!deletingField
36143
+ });
36144
+ const createMutation = useMutation({
36145
+ mutationFn: (input) => createBylineField(input),
36146
+ onSuccess: (field) => {
36147
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
36148
+ toastManager.add({
36149
+ title: _t({
36150
+ id: "5fyK5P",
36151
+ message: "Field created"
36152
+ }),
36153
+ description: _t({
36154
+ id: "CmHSIV",
36155
+ message: "Created \"{0}\".",
36156
+ values: { 0: field.label }
36157
+ })
36158
+ });
36159
+ setCreating(false);
36160
+ },
36161
+ onError: (mutationError) => {
36162
+ toastManager.add({
36163
+ title: _t({
36164
+ id: "WWQD//",
36165
+ message: "Failed to create field"
36166
+ }),
36167
+ description: mutationError instanceof Error ? mutationError.message : _t({
36168
+ id: "Vw8l6h",
36169
+ message: "An error occurred"
36170
+ }),
36171
+ type: "error"
36172
+ });
36173
+ }
36174
+ });
36175
+ const updateMutation = useMutation({
36176
+ mutationFn: (vars) => updateBylineField(vars.slug, vars.input),
36177
+ onSuccess: (field) => {
36178
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
36179
+ toastManager.add({
36180
+ title: _t({
36181
+ id: "oaEgX0",
36182
+ message: "Field updated"
36183
+ }),
36184
+ description: _t({
36185
+ id: "m64wqx",
36186
+ message: "Saved \"{0}\".",
36187
+ values: { 0: field.label }
36188
+ })
36189
+ });
36190
+ setEditingField(null);
36191
+ },
36192
+ onError: (mutationError) => {
36193
+ toastManager.add({
36194
+ title: _t({
36195
+ id: "nfiPO9",
36196
+ message: "Failed to save field"
36197
+ }),
36198
+ description: mutationError instanceof Error ? mutationError.message : _t({
36199
+ id: "Vw8l6h",
36200
+ message: "An error occurred"
36201
+ }),
36202
+ type: "error"
36203
+ });
36204
+ }
36205
+ });
36206
+ const deleteMutation = useMutation({
36207
+ mutationFn: (slug) => deleteBylineField(slug),
36208
+ onSuccess: () => {
36209
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
36210
+ toastManager.add({ title: _t({
36211
+ id: "LX8Pu1",
36212
+ message: "Field deleted"
36213
+ }) });
36214
+ setDeletingField(null);
36215
+ },
36216
+ onError: (mutationError) => {
36217
+ toastManager.add({
36218
+ title: _t({
36219
+ id: "e0xtVp",
36220
+ message: "Failed to delete field"
36221
+ }),
36222
+ description: mutationError instanceof Error ? mutationError.message : _t({
36223
+ id: "Vw8l6h",
36224
+ message: "An error occurred"
36225
+ }),
36226
+ type: "error"
36227
+ });
36228
+ }
36229
+ });
36230
+ const reorderMutation = useMutation({
36231
+ mutationFn: (slugs) => reorderBylineFields(slugs),
36232
+ onSuccess: () => {
36233
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
36234
+ },
36235
+ onError: (mutationError) => {
36236
+ toastManager.add({
36237
+ title: _t({
36238
+ id: "eGNX2z",
36239
+ message: "Failed to reorder fields"
36240
+ }),
36241
+ description: mutationError instanceof Error ? mutationError.message : _t({
36242
+ id: "Vw8l6h",
36243
+ message: "An error occurred"
36244
+ }),
36245
+ type: "error"
36246
+ });
36247
+ }
36248
+ });
36249
+ if (userLoading) return /* @__PURE__ */ jsx("div", {
36250
+ className: "flex items-center justify-center min-h-[50vh]",
36251
+ children: /* @__PURE__ */ jsx(Loader, {})
36252
+ });
36253
+ if (!currentUser || currentUser.role < ROLE_ADMIN$1) return /* @__PURE__ */ jsx("div", {
36254
+ className: "flex items-center justify-center min-h-[50vh]",
36255
+ children: /* @__PURE__ */ jsxs("div", {
36256
+ className: "text-center",
36257
+ children: [/* @__PURE__ */ jsx("h1", {
36258
+ className: "text-2xl font-bold",
36259
+ children: _t({
36260
+ id: "S2Rr8h",
36261
+ message: "Access denied"
36262
+ })
36263
+ }), /* @__PURE__ */ jsx("p", {
36264
+ className: "mt-2 text-kumo-subtle",
36265
+ children: _t({
36266
+ id: "MKw3nY",
36267
+ message: "You need admin permissions to manage byline schema."
36268
+ })
36269
+ })]
36270
+ })
36271
+ });
36272
+ const fields = fieldsQuery.data?.items ?? [];
36273
+ const handleMove = (index, delta) => {
36274
+ const targetIndex = index + delta;
36275
+ if (targetIndex < 0 || targetIndex >= fields.length) return;
36276
+ const next = [...fields];
36277
+ const a = next[index];
36278
+ const b = next[targetIndex];
36279
+ if (!a || !b) return;
36280
+ next[index] = b;
36281
+ next[targetIndex] = a;
36282
+ reorderMutation.mutate(next.map((f) => f.slug));
36283
+ };
36284
+ const handleCreate = (input) => {
36285
+ if ("slug" in input && "type" in input) createMutation.mutate(input);
36286
+ };
36287
+ const handleEdit = (input) => {
36288
+ if (!editingField) return;
36289
+ if (!("slug" in input) && !("type" in input)) updateMutation.mutate({
36290
+ slug: editingField.slug,
36291
+ input
36292
+ });
36293
+ };
36294
+ return /* @__PURE__ */ jsxs("div", {
36295
+ className: "space-y-4",
36296
+ children: [
36297
+ /* @__PURE__ */ jsxs("div", {
36298
+ className: "flex items-center justify-between",
36299
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
36300
+ className: "text-2xl font-bold",
36301
+ children: _t({
36302
+ id: "5YHQju",
36303
+ message: "Byline schema"
36304
+ })
36305
+ }), /* @__PURE__ */ jsx("p", {
36306
+ className: "text-kumo-subtle text-sm",
36307
+ children: _t({
36308
+ id: "yYyG5z",
36309
+ message: "Define custom fields stored on every byline — job title, pronouns, social handles, and more."
36310
+ })
36311
+ })] }), /* @__PURE__ */ jsx(Button, {
36312
+ icon: /* @__PURE__ */ jsx(Plus, {}),
36313
+ onClick: () => setCreating(true),
36314
+ children: _t({
36315
+ id: "kV1LSq",
36316
+ message: "New field"
36317
+ })
36318
+ })]
36319
+ }),
36320
+ /* @__PURE__ */ jsx("div", {
36321
+ className: "rounded-md border bg-kumo-base overflow-x-auto",
36322
+ children: /* @__PURE__ */ jsxs("table", {
36323
+ className: "w-full",
36324
+ children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", {
36325
+ className: "border-b bg-kumo-tint/50",
36326
+ children: [
36327
+ /* @__PURE__ */ jsx("th", {
36328
+ scope: "col",
36329
+ className: "px-4 py-3 text-start text-sm font-medium",
36330
+ children: _t({
36331
+ id: "87a/t/",
36332
+ message: "Label"
36333
+ })
36334
+ }),
36335
+ /* @__PURE__ */ jsx("th", {
36336
+ scope: "col",
36337
+ className: "px-4 py-3 text-start text-sm font-medium",
36338
+ children: _t({
36339
+ id: "L85WcV",
36340
+ message: "Slug"
36341
+ })
36342
+ }),
36343
+ /* @__PURE__ */ jsx("th", {
36344
+ scope: "col",
36345
+ className: "px-4 py-3 text-start text-sm font-medium",
36346
+ children: _t({
36347
+ id: "+zy2Nq",
36348
+ message: "Type"
36349
+ })
36350
+ }),
36351
+ /* @__PURE__ */ jsx("th", {
36352
+ scope: "col",
36353
+ className: "px-4 py-3 text-start text-sm font-medium",
36354
+ children: _t({
36355
+ id: "O/SdND",
36356
+ message: "Translatable"
36357
+ })
36358
+ }),
36359
+ /* @__PURE__ */ jsx("th", {
36360
+ scope: "col",
36361
+ className: "px-4 py-3 text-start text-sm font-medium",
36362
+ children: _t({
36363
+ id: "TMLAx2",
36364
+ message: "Required"
36365
+ })
36366
+ }),
36367
+ /* @__PURE__ */ jsx("th", {
36368
+ scope: "col",
36369
+ className: "px-4 py-3 text-end text-sm font-medium",
36370
+ children: _t({
36371
+ id: "7L01XJ",
36372
+ message: "Actions"
36373
+ })
36374
+ })
36375
+ ]
36376
+ }) }), /* @__PURE__ */ jsx("tbody", {
36377
+ className: "divide-y divide-kumo-line",
36378
+ children: fieldsQuery.isLoading ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
36379
+ colSpan: 6,
36380
+ className: "px-4 py-8 text-center text-kumo-subtle",
36381
+ children: _t({
36382
+ id: "IMm99K",
36383
+ message: "Loading byline fields…"
36384
+ })
36385
+ }) }) : fieldsQuery.isError ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsxs("td", {
36386
+ colSpan: 6,
36387
+ className: "px-4 py-8 text-center",
36388
+ children: [
36389
+ /* @__PURE__ */ jsx("p", {
36390
+ className: "text-kumo-danger font-medium",
36391
+ children: _t({
36392
+ id: "ORkiLh",
36393
+ message: "Couldn't load byline fields."
36394
+ })
36395
+ }),
36396
+ /* @__PURE__ */ jsx("p", {
36397
+ className: "text-sm text-kumo-subtle mt-1",
36398
+ children: fieldsQuery.error instanceof Error ? fieldsQuery.error.message : _t({
36399
+ id: "W5A0Ly",
36400
+ message: "An unexpected error occurred."
36401
+ })
36402
+ }),
36403
+ /* @__PURE__ */ jsx(Button, {
36404
+ variant: "outline",
36405
+ size: "sm",
36406
+ className: "mt-3",
36407
+ onClick: () => void fieldsQuery.refetch(),
36408
+ children: _t({
36409
+ id: "6gRgw8",
36410
+ message: "Retry"
36411
+ })
36412
+ })
36413
+ ]
36414
+ }) }) : fields.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsxs("td", {
36415
+ colSpan: 6,
36416
+ className: "px-4 py-12 text-center text-kumo-subtle",
36417
+ children: [
36418
+ /* @__PURE__ */ jsx(IdentificationCard, {
36419
+ className: "mx-auto h-8 w-8 mb-3 text-kumo-subtle",
36420
+ "aria-hidden": "true"
36421
+ }),
36422
+ /* @__PURE__ */ jsx("p", { children: _t({
36423
+ id: "7XUltI",
36424
+ message: "No byline fields yet."
36425
+ }) }),
36426
+ /* @__PURE__ */ jsx("p", {
36427
+ className: "text-xs mt-1",
36428
+ children: _t({
36429
+ id: "USA0K0",
36430
+ message: "Add fields like \"Job title\" or \"Pronouns\" to enrich every byline."
36431
+ })
36432
+ })
36433
+ ]
36434
+ }) }) : fields.map((field, index) => /* @__PURE__ */ jsx(FieldRow, {
36435
+ field,
36436
+ isFirst: index === 0,
36437
+ isLast: index === fields.length - 1,
36438
+ onMoveUp: () => handleMove(index, -1),
36439
+ onMoveDown: () => handleMove(index, 1),
36440
+ onEdit: () => setEditingField(field),
36441
+ onDelete: () => setDeletingField(field),
36442
+ reorderPending: reorderMutation.isPending
36443
+ }, field.id))
36444
+ })]
36445
+ })
36446
+ }),
36447
+ /* @__PURE__ */ jsx(BylineFieldEditor, {
36448
+ open: creating,
36449
+ onOpenChange: (open) => {
36450
+ if (!open) {
36451
+ setCreating(false);
36452
+ createMutation.reset();
36453
+ }
36454
+ },
36455
+ onSave: handleCreate,
36456
+ isSaving: createMutation.isPending,
36457
+ error: createMutation.error
36458
+ }),
36459
+ editingField && /* @__PURE__ */ jsx(BylineFieldEditor, {
36460
+ open: !!editingField,
36461
+ onOpenChange: (open) => {
36462
+ if (!open) {
36463
+ setEditingField(null);
36464
+ updateMutation.reset();
36465
+ }
36466
+ },
36467
+ field: editingField,
36468
+ usageTotal: usageQuery.data?.totalAffectedRows ?? 0,
36469
+ onSave: handleEdit,
36470
+ isSaving: updateMutation.isPending,
36471
+ error: updateMutation.error
36472
+ }, editingField.id),
36473
+ /* @__PURE__ */ jsx(ConfirmDialog, {
36474
+ open: !!deletingField,
36475
+ onClose: () => {
36476
+ setDeletingField(null);
36477
+ deleteMutation.reset();
36478
+ },
36479
+ title: _t({
36480
+ id: "S353b7",
36481
+ message: "Delete byline field?"
36482
+ }),
36483
+ description: deletingField ? deleteUsageQuery.isLoading ? _t({
36484
+ id: "WeKBmZ",
36485
+ message: "Checking how many stored values reference \"{0}\"…",
36486
+ values: { 0: deletingField.label }
36487
+ }) : deleteUsageQuery.isError ? _t({
36488
+ id: "AsyqqW",
36489
+ message: "Couldn't verify how many values reference \"{0}\". Deleting will still remove every stored value for this field — but the count above could not be checked.",
36490
+ values: { 0: deletingField.label }
36491
+ }) : deleteUsageQuery.data && deleteUsageQuery.data.totalAffectedRows > 0 ? _t({
36492
+ id: "YjS/z6",
36493
+ message: "Deleting \"{0}\" will also remove {1, plural, one {# stored value} other {# stored values}} across all bylines. This cannot be undone.",
36494
+ values: {
36495
+ 0: deletingField.label,
36496
+ 1: deleteUsageQuery.data.totalAffectedRows
36497
+ }
36498
+ }) : _t({
36499
+ id: "27Gnm/",
36500
+ message: "Are you sure you want to delete \"{0}\"? No stored values reference this field.",
36501
+ values: { 0: deletingField.label }
36502
+ }) : "",
36503
+ confirmLabel: _t({
36504
+ id: "cnGeoo",
36505
+ message: "Delete"
36506
+ }),
36507
+ pendingLabel: _t({
36508
+ id: "gXQX4x",
36509
+ message: "Deleting…"
36510
+ }),
36511
+ isPending: deleteMutation.isPending,
36512
+ error: deleteMutation.error,
36513
+ onConfirm: () => {
36514
+ if (deletingField) deleteMutation.mutate(deletingField.slug);
36515
+ }
36516
+ })
36517
+ ]
36518
+ });
36519
+ }
36520
+ function FieldRow({ field, isFirst, isLast, onMoveUp, onMoveDown, onEdit, onDelete, reorderPending }) {
36521
+ const { _: _t2 } = useLingui();
36522
+ return /* @__PURE__ */ jsxs("tr", {
36523
+ className: "hover:bg-kumo-tint/25",
36524
+ children: [
36525
+ /* @__PURE__ */ jsx("td", {
36526
+ className: "px-4 py-3 font-medium",
36527
+ children: field.label
36528
+ }),
36529
+ /* @__PURE__ */ jsx("td", {
36530
+ className: "px-4 py-3",
36531
+ children: /* @__PURE__ */ jsx("code", {
36532
+ className: "text-sm bg-kumo-tint px-1.5 py-0.5 rounded",
36533
+ children: field.slug
36534
+ })
36535
+ }),
36536
+ /* @__PURE__ */ jsx("td", {
36537
+ className: "px-4 py-3 text-sm",
36538
+ children: field.type
36539
+ }),
36540
+ /* @__PURE__ */ jsx("td", {
36541
+ className: "px-4 py-3 text-sm",
36542
+ children: field.translatable ? _t2({
36543
+ id: "l75CjT",
36544
+ message: "Yes"
36545
+ }) : _t2({
36546
+ id: "Vqmggp",
36547
+ message: "No (shared across translations)"
36548
+ })
36549
+ }),
36550
+ /* @__PURE__ */ jsx("td", {
36551
+ className: "px-4 py-3 text-sm",
36552
+ children: field.required ? _t2({
36553
+ id: "l75CjT",
36554
+ message: "Yes"
36555
+ }) : _t2({
36556
+ id: "1UzENP",
36557
+ message: "No"
36558
+ })
36559
+ }),
36560
+ /* @__PURE__ */ jsx("td", {
36561
+ className: "px-4 py-3 text-end",
36562
+ children: /* @__PURE__ */ jsxs("div", {
36563
+ className: "flex items-center justify-end gap-1",
36564
+ children: [
36565
+ /* @__PURE__ */ jsx(Button, {
36566
+ variant: "ghost",
36567
+ shape: "square",
36568
+ size: "sm",
36569
+ "aria-label": _t2({
36570
+ id: "ko+NNU",
36571
+ message: "Move \"{0}\" up",
36572
+ values: { 0: field.label }
36573
+ }),
36574
+ disabled: isFirst || reorderPending,
36575
+ onClick: onMoveUp,
36576
+ children: /* @__PURE__ */ jsx(ArrowUp, {
36577
+ className: "h-4 w-4",
36578
+ "aria-hidden": "true"
36579
+ })
36580
+ }),
36581
+ /* @__PURE__ */ jsx(Button, {
36582
+ variant: "ghost",
36583
+ shape: "square",
36584
+ size: "sm",
36585
+ "aria-label": _t2({
36586
+ id: "Q23Rqr",
36587
+ message: "Move \"{0}\" down",
36588
+ values: { 0: field.label }
36589
+ }),
36590
+ disabled: isLast || reorderPending,
36591
+ onClick: onMoveDown,
36592
+ children: /* @__PURE__ */ jsx(ArrowDown, {
36593
+ className: "h-4 w-4",
36594
+ "aria-hidden": "true"
36595
+ })
36596
+ }),
36597
+ /* @__PURE__ */ jsx(Button, {
36598
+ variant: "ghost",
36599
+ shape: "square",
36600
+ size: "sm",
36601
+ "aria-label": _t2({
36602
+ id: "N6j2JH",
36603
+ message: "Edit {0}",
36604
+ values: { 0: field.label }
36605
+ }),
36606
+ onClick: onEdit,
36607
+ children: /* @__PURE__ */ jsx(Pencil, {
36608
+ className: "h-4 w-4",
36609
+ "aria-hidden": "true"
36610
+ })
36611
+ }),
36612
+ /* @__PURE__ */ jsx(Button, {
36613
+ variant: "ghost",
36614
+ shape: "square",
36615
+ size: "sm",
36616
+ "aria-label": _t2({
36617
+ id: "Y2tU6I",
36618
+ message: "Delete {0}",
36619
+ values: { 0: field.label }
36620
+ }),
36621
+ onClick: onDelete,
36622
+ children: /* @__PURE__ */ jsx(Trash, {
36623
+ className: "h-4 w-4 text-kumo-danger",
36624
+ "aria-hidden": "true"
36625
+ })
36626
+ })
36627
+ ]
36628
+ })
36629
+ })
36630
+ ]
36631
+ });
36632
+ }
36633
+
35288
36634
  //#endregion
35289
36635
  //#region src/routes/bylines.tsx
35290
36636
  /**
@@ -35301,7 +36647,8 @@ function toFormState(byline) {
35301
36647
  bio: "",
35302
36648
  websiteUrl: "",
35303
36649
  userId: null,
35304
- isGuest: false
36650
+ isGuest: false,
36651
+ customFields: {}
35305
36652
  };
35306
36653
  return {
35307
36654
  slug: byline.slug,
@@ -35309,7 +36656,8 @@ function toFormState(byline) {
35309
36656
  bio: byline.bio ?? "",
35310
36657
  websiteUrl: byline.websiteUrl ?? "",
35311
36658
  userId: byline.userId,
35312
- isGuest: byline.isGuest
36659
+ isGuest: byline.isGuest,
36660
+ customFields: byline.customFields ?? {}
35313
36661
  };
35314
36662
  }
35315
36663
  function getUserLabel(user) {
@@ -35322,6 +36670,7 @@ function BylinesPage() {
35322
36670
  const navigate = useNavigate$1();
35323
36671
  const { locale: routeLocale } = useSearch({ from: "/_admin/bylines" });
35324
36672
  const [search, setSearch] = React$1.useState("");
36673
+ const debouncedSearch = useDebouncedValue$1(search, 300);
35325
36674
  const [guestFilter, setGuestFilter] = React$1.useState("all");
35326
36675
  const [selectedId, setSelectedId] = React$1.useState(null);
35327
36676
  const [showDeleteConfirm, setShowDeleteConfirm] = React$1.useState(false);
@@ -35333,6 +36682,8 @@ function BylinesPage() {
35333
36682
  });
35334
36683
  const i18n = useI18nConfig(manifest);
35335
36684
  const isMultiLocale = !!i18n && i18n.locales.length > 1;
36685
+ const { data: currentUser } = useCurrentUser();
36686
+ const canManageBylineSchema = (currentUser?.role ?? 0) >= BYLINE_SCHEMA_NAV_ITEM.minRole;
35336
36687
  const activeLocale = routeLocale ?? i18n?.defaultLocale ?? void 0;
35337
36688
  const handleLocaleChange = (locale) => {
35338
36689
  navigate({
@@ -35344,12 +36695,12 @@ function BylinesPage() {
35344
36695
  const { data, isLoading, error } = useQuery({
35345
36696
  queryKey: [
35346
36697
  "bylines",
35347
- search,
36698
+ debouncedSearch,
35348
36699
  guestFilter,
35349
36700
  activeLocale ?? null
35350
36701
  ],
35351
36702
  queryFn: () => fetchBylines({
35352
- search: search || void 0,
36703
+ search: debouncedSearch || void 0,
35353
36704
  isGuest: guestFilter === "all" ? void 0 : guestFilter === "guest",
35354
36705
  locale: activeLocale,
35355
36706
  limit: 50
@@ -35366,6 +36717,12 @@ function BylinesPage() {
35366
36717
  queryFn: () => fetchUsers({ limit: 100 })
35367
36718
  });
35368
36719
  const users = usersData?.items ?? [];
36720
+ const { data: customFieldsList, error: customFieldsError } = useQuery({
36721
+ queryKey: ["byline-fields"],
36722
+ queryFn: listBylineFields,
36723
+ staleTime: 60 * 1e3
36724
+ });
36725
+ const customFieldDefs = customFieldsList?.items ?? [];
35369
36726
  const loadMoreMutation = useMutation({
35370
36727
  mutationFn: async (snapshot) => {
35371
36728
  return {
@@ -35381,7 +36738,7 @@ function BylinesPage() {
35381
36738
  },
35382
36739
  onSuccess: ({ result, snapshot }) => {
35383
36740
  if (!loadMoreSnapshotMatches(snapshot, {
35384
- search,
36741
+ search: debouncedSearch,
35385
36742
  guestFilter,
35386
36743
  locale: activeLocale
35387
36744
  })) return;
@@ -35406,31 +36763,38 @@ function BylinesPage() {
35406
36763
  enabled: !!selectedId && isMultiLocale
35407
36764
  });
35408
36765
  const createMutation = useMutation({
35409
- mutationFn: () => createByline({
35410
- slug: form.slug,
35411
- displayName: form.displayName,
35412
- bio: form.bio || null,
35413
- websiteUrl: form.websiteUrl || null,
35414
- userId: form.userId,
35415
- isGuest: form.isGuest,
35416
- locale: activeLocale
35417
- }),
36766
+ mutationFn: () => {
36767
+ const body = {
36768
+ slug: form.slug,
36769
+ displayName: form.displayName,
36770
+ bio: form.bio || null,
36771
+ websiteUrl: form.websiteUrl || null,
36772
+ userId: form.userId,
36773
+ isGuest: form.isGuest,
36774
+ locale: activeLocale
36775
+ };
36776
+ if (!customFieldsError && Object.keys(form.customFields).length > 0) body.customFields = form.customFields;
36777
+ return createByline(body);
36778
+ },
35418
36779
  onSuccess: (created) => {
35419
36780
  queryClient.invalidateQueries({ queryKey: ["bylines"] });
36781
+ queryClient.invalidateQueries({ queryKey: ["byline", created.id] });
35420
36782
  setSelectedId(created.id);
35421
36783
  }
35422
36784
  });
35423
36785
  const updateMutation = useMutation({
35424
36786
  mutationFn: () => {
35425
36787
  if (!selectedId) throw new Error("No byline selected");
35426
- return updateByline(selectedId, {
36788
+ const body = {
35427
36789
  slug: form.slug,
35428
36790
  displayName: form.displayName,
35429
36791
  bio: form.bio || null,
35430
36792
  websiteUrl: form.websiteUrl || null,
35431
36793
  userId: form.userId,
35432
36794
  isGuest: form.isGuest
35433
- });
36795
+ };
36796
+ if (!customFieldsError) body.customFields = form.customFields;
36797
+ return updateByline(selectedId, body);
35434
36798
  },
35435
36799
  onSuccess: () => {
35436
36800
  queryClient.invalidateQueries({ queryKey: ["bylines"] });
@@ -35468,7 +36832,7 @@ function BylinesPage() {
35468
36832
  setSelectedId(created.id);
35469
36833
  }
35470
36834
  });
35471
- if (isLoading) return /* @__PURE__ */ jsx("div", {
36835
+ if (isLoading && !data) return /* @__PURE__ */ jsx("div", {
35472
36836
  className: "flex items-center justify-center min-h-[30vh]",
35473
36837
  children: /* @__PURE__ */ jsx(Loader, {})
35474
36838
  });
@@ -35485,21 +36849,32 @@ function BylinesPage() {
35485
36849
  return /* @__PURE__ */ jsxs("div", {
35486
36850
  className: "space-y-4",
35487
36851
  children: [
35488
- isMultiLocale && i18n && activeLocale ? /* @__PURE__ */ jsxs("div", {
35489
- className: "flex items-center justify-between",
35490
- children: [/* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx("h1", {
36852
+ /* @__PURE__ */ jsxs("div", {
36853
+ className: "flex items-center justify-between gap-3",
36854
+ children: [/* @__PURE__ */ jsx("h1", {
35491
36855
  className: "text-2xl font-semibold",
35492
36856
  children: _t({
35493
36857
  id: "xLGa7J",
35494
36858
  message: "Bylines"
35495
36859
  })
35496
- }) }), /* @__PURE__ */ jsx(LocaleSwitcher, {
35497
- locales: i18n.locales,
35498
- defaultLocale: i18n.defaultLocale,
35499
- value: activeLocale,
35500
- onChange: handleLocaleChange
36860
+ }), /* @__PURE__ */ jsxs("div", {
36861
+ className: "flex items-center gap-2",
36862
+ children: [canManageBylineSchema && /* @__PURE__ */ jsx(RouterLinkButton, {
36863
+ to: BYLINE_SCHEMA_NAV_ITEM.to,
36864
+ variant: "secondary",
36865
+ icon: /* @__PURE__ */ jsx(IdentificationCard, {}),
36866
+ children: _t({
36867
+ id: "5YHQju",
36868
+ message: "Byline schema"
36869
+ })
36870
+ }), isMultiLocale && i18n && activeLocale && /* @__PURE__ */ jsx(LocaleSwitcher, {
36871
+ locales: i18n.locales,
36872
+ defaultLocale: i18n.defaultLocale,
36873
+ value: activeLocale,
36874
+ onChange: handleLocaleChange
36875
+ })]
35501
36876
  })]
35502
- }) : null,
36877
+ }),
35503
36878
  /* @__PURE__ */ jsxs("div", {
35504
36879
  className: "grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]",
35505
36880
  children: [/* @__PURE__ */ jsxs("div", {
@@ -35586,7 +36961,7 @@ function BylinesPage() {
35586
36961
  variant: "secondary",
35587
36962
  className: "w-full mt-2",
35588
36963
  onClick: () => loadMoreMutation.mutate({
35589
- search,
36964
+ search: debouncedSearch,
35590
36965
  guestFilter,
35591
36966
  locale: activeLocale,
35592
36967
  cursor: nextCursor
@@ -35687,6 +37062,33 @@ function BylinesPage() {
35687
37062
  },
35688
37063
  className: "w-full"
35689
37064
  }),
37065
+ customFieldDefs.length > 0 && customFieldDefs.map((field) => /* @__PURE__ */ jsx(CustomFieldInput, {
37066
+ field,
37067
+ value: form.customFields[field.slug],
37068
+ onChange: (next) => setForm((prev) => ({
37069
+ ...prev,
37070
+ customFields: {
37071
+ ...prev.customFields,
37072
+ [field.slug]: next
37073
+ }
37074
+ }))
37075
+ }, field.id)),
37076
+ customFieldsError && /* @__PURE__ */ jsxs("div", {
37077
+ className: "rounded-md border border-kumo-danger/40 bg-kumo-danger/5 p-3 text-sm",
37078
+ children: [/* @__PURE__ */ jsx("p", {
37079
+ className: "font-medium text-kumo-danger",
37080
+ children: _t({
37081
+ id: "Jci99j",
37082
+ message: "Couldn't load custom fields."
37083
+ })
37084
+ }), /* @__PURE__ */ jsx("p", {
37085
+ className: "text-xs text-kumo-subtle mt-1",
37086
+ children: _t({
37087
+ id: "X+FaLl",
37088
+ message: "You can still edit the fixed fields above. Saving will not touch any stored custom-field values."
37089
+ })
37090
+ })]
37091
+ }),
35690
37092
  /* @__PURE__ */ jsx(Switch, {
35691
37093
  label: _t({
35692
37094
  id: "6CRaxp",
@@ -35780,6 +37182,66 @@ function BylinesPage() {
35780
37182
  ]
35781
37183
  });
35782
37184
  }
37185
+ /**
37186
+ * Renders a single registered byline custom field as the appropriate
37187
+ * Kumo input for its type (Phase 6 of #1174).
37188
+ *
37189
+ * Five v1 type cases mirror `BylineFieldType` and the inputs that
37190
+ * `BylineFieldEditor` allows admins to register. Empty string inputs
37191
+ * coerce to `null` on save so the repo's "null clears the row"
37192
+ * storage semantic engages — server-side `BylineRepository.update`
37193
+ * deletes the value row rather than storing an empty-string JSON.
37194
+ *
37195
+ * `field.required` adds a `*` after the label as a visual hint; the
37196
+ * server is authoritative on validation (Phase 6 ACs don't include a
37197
+ * client-side required check — the registry's `required` flag is
37198
+ * descriptive rather than enforced in the write path today).
37199
+ */
37200
+ function CustomFieldInput({ field, value, onChange }) {
37201
+ const { _: _t2 } = useLingui();
37202
+ const label = field.required ? `${field.label} *` : field.label;
37203
+ const stringValue = typeof value === "string" ? value : "";
37204
+ switch (field.type) {
37205
+ case "string": return /* @__PURE__ */ jsx(Input, {
37206
+ label,
37207
+ value: stringValue,
37208
+ onChange: (e) => onChange(e.target.value === "" ? null : e.target.value)
37209
+ });
37210
+ case "text": return /* @__PURE__ */ jsx(InputArea, {
37211
+ label,
37212
+ value: stringValue,
37213
+ onChange: (e) => onChange(e.target.value === "" ? null : e.target.value),
37214
+ rows: 3
37215
+ });
37216
+ case "url": return /* @__PURE__ */ jsx(Input, {
37217
+ type: "url",
37218
+ label,
37219
+ value: stringValue,
37220
+ onChange: (e) => onChange(e.target.value === "" ? null : e.target.value)
37221
+ });
37222
+ case "boolean": return /* @__PURE__ */ jsx(Switch, {
37223
+ label,
37224
+ checked: value === true,
37225
+ onCheckedChange: (checked) => onChange(checked)
37226
+ });
37227
+ case "select": {
37228
+ const options = field.validation?.options ?? [];
37229
+ const items = Object.create(null);
37230
+ items[""] = _t2({
37231
+ id: "9npDF6",
37232
+ message: "-- Select --"
37233
+ });
37234
+ for (const opt of options) items[opt] = opt;
37235
+ return /* @__PURE__ */ jsx(Select, {
37236
+ label,
37237
+ value: stringValue,
37238
+ onValueChange: (v) => onChange(!v ? null : v),
37239
+ items,
37240
+ className: "w-full"
37241
+ });
37242
+ }
37243
+ }
37244
+ }
35783
37245
 
35784
37246
  //#endregion
35785
37247
  //#region src/components/users/RoleBadge.tsx
@@ -37143,13 +38605,15 @@ function ContentListPage() {
37143
38605
  field: "updatedAt",
37144
38606
  direction: "desc"
37145
38607
  });
38608
+ const [searchTerm, setSearchTerm] = React$1.useState("");
37146
38609
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({
37147
38610
  queryKey: [
37148
38611
  "content",
37149
38612
  collection,
37150
38613
  {
37151
38614
  locale: activeLocale,
37152
- sort
38615
+ sort,
38616
+ search: searchTerm
37153
38617
  }
37154
38618
  ],
37155
38619
  queryFn: ({ pageParam }) => fetchContentList(collection, {
@@ -37157,7 +38621,8 @@ function ContentListPage() {
37157
38621
  cursor: pageParam,
37158
38622
  limit: 100,
37159
38623
  orderBy: sort.field,
37160
- order: sort.direction
38624
+ order: sort.direction,
38625
+ search: searchTerm || void 0
37161
38626
  }),
37162
38627
  initialPageParam: void 0,
37163
38628
  getNextPageParam: (lastPage) => lastPage.nextCursor,
@@ -37296,7 +38761,8 @@ function ContentListPage() {
37296
38761
  urlPattern: collectionConfig.urlPattern,
37297
38762
  sort,
37298
38763
  onSortChange: setSort,
37299
- total
38764
+ total,
38765
+ onSearchChange: setSearchTerm
37300
38766
  });
37301
38767
  }
37302
38768
  const contentNewRoute = createRoute({
@@ -37911,11 +39377,18 @@ const mediaRoute = createRoute({
37911
39377
  });
37912
39378
  function MediaPage() {
37913
39379
  const queryClient = useQueryClient();
39380
+ const [search, setSearch] = React$1.useState("");
39381
+ const [mimeFilter, setMimeFilter] = React$1.useState(void 0);
37914
39382
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({
37915
- queryKey: ["media"],
39383
+ queryKey: ["media", {
39384
+ search,
39385
+ mime: Array.isArray(mimeFilter) ? mimeFilter.join(",") : mimeFilter ?? ""
39386
+ }],
37916
39387
  queryFn: ({ pageParam }) => fetchMediaList({
37917
39388
  cursor: pageParam,
37918
- limit: 100
39389
+ limit: 100,
39390
+ search: search || void 0,
39391
+ mimeType: mimeFilter
37919
39392
  }),
37920
39393
  initialPageParam: void 0,
37921
39394
  getNextPageParam: (lastPage) => lastPage.nextCursor
@@ -37942,7 +39415,9 @@ function MediaPage() {
37942
39415
  hasMore: !!hasNextPage,
37943
39416
  onLoadMore: () => void fetchNextPage(),
37944
39417
  onUpload: (file) => uploadMutation.mutate(file),
37945
- onDelete: (id) => deleteMutation.mutate(id)
39418
+ onDelete: (id) => deleteMutation.mutate(id),
39419
+ onLocalSearchChange: setSearch,
39420
+ onLocalMimeFilterChange: setMimeFilter
37946
39421
  });
37947
39422
  }
37948
39423
  const commentsRoute = createRoute({
@@ -38309,6 +39784,11 @@ const bylinesRoute = createRoute({
38309
39784
  component: BylinesPage,
38310
39785
  validateSearch: parseBylinesLocaleSearch
38311
39786
  });
39787
+ const bylineSchemaRoute = createRoute({
39788
+ getParentRoute: () => adminLayoutRoute,
39789
+ path: "/byline-schema",
39790
+ component: BylineSchemaPage
39791
+ });
38312
39792
  const contentTypesListRoute = createRoute({
38313
39793
  getParentRoute: () => adminLayoutRoute,
38314
39794
  path: "/content-types",
@@ -38535,6 +40015,7 @@ const adminRoutes = adminLayoutRoute.addChildren([
38535
40015
  taxonomyRoute,
38536
40016
  usersRoute,
38537
40017
  bylinesRoute,
40018
+ bylineSchemaRoute,
38538
40019
  widgetsRoute,
38539
40020
  settingsRoute,
38540
40021
  generalSettingsRoute,
@@ -38692,5 +40173,5 @@ function AdminApp({ pluginAdmins = EMPTY_PLUGINS, authProviders = EMPTY_AUTH_PRO
38692
40173
  }
38693
40174
 
38694
40175
  //#endregion
38695
- export { API_BASE, API_TOKEN_SCOPES, AdminApp, AdminApp as App, AuthProviderProvider, CAPABILITY_LABELS, ContentEditor, ContentList, DEFAULT_LOCALE, Dashboard, EditorHeader, Header, Link, LoginPage, MediaLibrary, MediaPickerModal, PasskeyLogin, PasskeyRegistration, PluginAdminProvider, PortableTextEditor, SUPPORTED_LOCALES, SUPPORTED_LOCALE_CODES, SaveButton, Settings, SetupWizard, Shell, KumoSidebar as Sidebar, SidebarNav, analyzeWpPluginSite, analyzeWxr, apiFetch, bulkCommentAction, checkPluginUpdates, cn, compareRevisions, completeSignup, createAdminRouter, createAllowedDomain, createApiToken, createByline, createBylineTranslation, createCollection, createContent, createField, createMenu, createMenuItem, createMenuTranslation, createRedirect, createSection, createTaxonomy, createTerm, createTermTranslation, createWidget, createWidgetArea, deleteAllowedDomain, deleteByline, deleteCollection, deleteComment, deleteContent, deleteField, deleteFromProvider, deleteMedia, deleteMenu, deleteMenuItem, deletePasskey, deleteRedirect, deleteSection, deleteTerm, deleteWidget, deleteWidgetArea, describeCapability, disablePlugin, disableUser, discardDraft, duplicateContent, enablePlugin, enableUser, executeWpPluginImport, executeWxrImport, fetch404Summary, fetchAllowedDomains, fetchApiTokens, fetchAuthMode, fetchByline, fetchBylineTranslations, fetchBylines, fetchCollection, fetchCollections, fetchComment, fetchCommentCounts, fetchComments, fetchContent, fetchContentList, fetchDashboardStats, fetchEmailSettings, fetchFields, fetchManifest, fetchMarketplacePlugin, fetchMediaList, fetchMediaProviders, fetchMenu, fetchMenuTranslations, fetchMenus, fetchOrphanedTables, fetchPasskeys, fetchPlugin, fetchPlugins, fetchProviderMedia, fetchRedirects, fetchRevision, fetchRevisions, fetchSection, fetchSections, fetchSettings, fetchTaxonomyDef, fetchTaxonomyDefs, fetchTermTranslations, fetchTerms, fetchTheme, fetchTranslations, fetchTrashedContent, fetchUser, fetchUsers, fetchWidgetArea, fetchWidgetAreas, fetchWidgetComponents, generatePreviewUrl, getDraftStatus, getPreviewUrl, hasAllowedDomains, importWxrMedia, installMarketplacePlugin, inviteUser, parseApiResponse, permanentDeleteContent, prepareWxrImport, probeImportUrl, publishContent, registerOrphanedTable, renamePasskey, reorderFields, reorderMenuItems, reorderWidgets, requestSignup, resolveLocale, restoreContent, restoreRevision, revokeApiToken, rewriteContentUrls, scheduleContent, searchMarketplace, searchThemes, sendRecoveryLink, sendTestEmail, setSearchEnabled, throwResponseError, uninstallMarketplacePlugin, unpublishContent, unscheduleContent, updateAllowedDomain, updateByline, updateCollection, updateCommentStatus, updateContent, updateField, updateMarketplacePlugin, updateMedia, updateMenu, updateMenuItem, updateRedirect, updateSection, updateSettings, updateTerm, updateUser, updateWidget, uploadMedia, uploadToProvider, useAuthProviderList, useAuthProviders, useCurrentUser, useLocale, useNavigate, useParams, usePluginAdmins, usePluginField, usePluginHasPages, usePluginHasWidgets, usePluginPage, usePluginWidget, validateInviteToken, verifySignupToken };
40176
+ export { API_BASE, API_TOKEN_SCOPES, AdminApp, AdminApp as App, AuthProviderProvider, CAPABILITY_LABELS, ContentEditor, ContentList, DEFAULT_LOCALE, Dashboard, EditorHeader, Header, Link, LoginPage, MEDIA_SEARCH_MAX_LENGTH, MediaLibrary, MediaPickerModal, PasskeyLogin, PasskeyRegistration, PluginAdminProvider, PortableTextEditor, SUPPORTED_LOCALES, SUPPORTED_LOCALE_CODES, SaveButton, Settings, SetupWizard, Shell, KumoSidebar as Sidebar, SidebarNav, analyzeWpPluginSite, analyzeWxr, apiFetch, bulkCommentAction, checkPluginUpdates, cn, compareRevisions, completeSignup, createAdminRouter, createAllowedDomain, createApiToken, createByline, createBylineField, createBylineTranslation, createCollection, createContent, createField, createMenu, createMenuItem, createMenuTranslation, createRedirect, createSection, createTaxonomy, createTerm, createTermTranslation, createWidget, createWidgetArea, deleteAllowedDomain, deleteByline, deleteBylineField, deleteCollection, deleteComment, deleteContent, deleteField, deleteFromProvider, deleteMedia, deleteMenu, deleteMenuItem, deletePasskey, deleteRedirect, deleteSection, deleteTerm, deleteWidget, deleteWidgetArea, describeCapability, disablePlugin, disableUser, discardDraft, duplicateContent, enablePlugin, enableUser, executeWpPluginImport, executeWxrImport, fetch404Summary, fetchAllowedDomains, fetchApiTokens, fetchAuthMode, fetchByline, fetchBylineTranslations, fetchBylines, fetchCollection, fetchCollections, fetchComment, fetchCommentCounts, fetchComments, fetchContent, fetchContentList, fetchDashboardStats, fetchEmailSettings, fetchFields, fetchManifest, fetchMarketplacePlugin, fetchMediaList, fetchMediaProviders, fetchMenu, fetchMenuTranslations, fetchMenus, fetchOrphanedTables, fetchPasskeys, fetchPlugin, fetchPlugins, fetchProviderMedia, fetchRedirects, fetchRevision, fetchRevisions, fetchSection, fetchSections, fetchSettings, fetchTaxonomyDef, fetchTaxonomyDefs, fetchTermTranslations, fetchTerms, fetchTheme, fetchTranslations, fetchTrashedContent, fetchUser, fetchUsers, fetchWidgetArea, fetchWidgetAreas, fetchWidgetComponents, generatePreviewUrl, getBylineFieldUsage, getDraftStatus, getPreviewUrl, hasAllowedDomains, importWxrMedia, installMarketplacePlugin, inviteUser, listBylineFields, parseApiResponse, permanentDeleteContent, prepareWxrImport, probeImportUrl, publishContent, registerOrphanedTable, renamePasskey, reorderBylineFields, reorderFields, reorderMenuItems, reorderWidgets, requestSignup, resolveLocale, restoreContent, restoreRevision, revokeApiToken, rewriteContentUrls, scheduleContent, searchMarketplace, searchThemes, sendRecoveryLink, sendTestEmail, setSearchEnabled, throwResponseError, uninstallMarketplacePlugin, unpublishContent, unscheduleContent, updateAllowedDomain, updateByline, updateBylineField, updateCollection, updateCommentStatus, updateContent, updateField, updateMarketplacePlugin, updateMedia, updateMenu, updateMenuItem, updateRedirect, updateSection, updateSettings, updateTerm, updateUser, updateWidget, uploadMedia, uploadToProvider, useAuthProviderList, useAuthProviders, useCurrentUser, useLocale, useNavigate, useParams, usePluginAdmins, usePluginField, usePluginHasPages, usePluginHasWidgets, usePluginPage, usePluginWidget, validateInviteToken, verifySignupToken };
38696
40177
  //# sourceMappingURL=index.js.map