@jini-ai/admin 0.4.5 → 0.4.6

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/agent-plugins/package-file-tree.d.ts +3 -1
  2. package/dist/agent-plugins/package-file-tree.js +7 -7
  3. package/dist/comments/controllers/comment-queue.controller.d.ts +5 -1
  4. package/dist/comments/controllers/comment-queue.controller.js +2 -2
  5. package/dist/comments/react/hooks/use-comment-queue.hooks.js +14 -3
  6. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -0
  7. package/dist/consent/react/ConsentSettingsPanel.js +24 -0
  8. package/dist/consent/react/index.d.ts +3 -0
  9. package/dist/consent/react/index.js +2 -0
  10. package/dist/consent/react/settings-panel.hooks.d.ts +19 -0
  11. package/dist/consent/react/settings-panel.hooks.js +30 -0
  12. package/dist/core/ports/forms.d.ts +23 -3
  13. package/dist/core/ports/forms.js +3 -3
  14. package/dist/core/ports/seo.d.ts +17 -0
  15. package/dist/forms/adapters/http.d.ts +5 -2
  16. package/dist/forms/adapters/http.js +7 -2
  17. package/dist/forms/controllers/field-attributes-dialog.controller.d.ts +59 -1
  18. package/dist/forms/controllers/field-attributes-dialog.controller.js +27 -2
  19. package/dist/forms/field-settings.d.ts +142 -0
  20. package/dist/forms/field-settings.js +327 -0
  21. package/dist/forms/form-editor-dirty.d.ts +42 -0
  22. package/dist/forms/form-editor-dirty.js +25 -0
  23. package/dist/forms/forms.module.d.ts +72 -0
  24. package/dist/forms/html-rules.d.ts +41 -3
  25. package/dist/forms/html-rules.js +118 -17
  26. package/dist/forms/messages.en.d.ts +72 -0
  27. package/dist/forms/messages.en.js +73 -0
  28. package/dist/forms/react/components/FieldAttributesDialog.js +20 -19
  29. package/dist/forms/react/components/FieldSettingsSection.d.ts +19 -0
  30. package/dist/forms/react/components/FieldSettingsSection.js +21 -0
  31. package/dist/forms/react/components/FormFieldsEditor.js +7 -8
  32. package/dist/forms/react/components/FormSubmissionDetail.js +5 -2
  33. package/dist/forms/react/hooks/forms-view.hooks.d.ts +75 -5
  34. package/dist/forms/react/hooks/forms-view.hooks.js +46 -2
  35. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.d.ts +10 -0
  36. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +12 -2
  37. package/dist/forms/react/hooks/use-form-editor.hooks.d.ts +11 -0
  38. package/dist/forms/react/hooks/use-form-editor.hooks.js +25 -2
  39. package/dist/forms/react/hooks/use-form-fields-editor.hooks.d.ts +2 -0
  40. package/dist/forms/react/hooks/use-form-fields-editor.hooks.js +5 -1
  41. package/dist/forms/react/hooks/use-form-submission-detail.hooks.d.ts +4 -0
  42. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -0
  43. package/dist/forms/react/index.d.ts +216 -0
  44. package/dist/forms/react/pages/FormEditor.js +3 -2
  45. package/dist/forms/rules.d.ts +10 -1
  46. package/dist/forms/rules.js +8 -1
  47. package/dist/media/controllers/library.controller.d.ts +4 -0
  48. package/dist/media/controllers/library.controller.js +13 -0
  49. package/dist/media/react/components/MediaPicker.js +2 -2
  50. package/dist/media/react/hooks/EditMediaPanel.hooks.js +4 -2
  51. package/dist/media/react/hooks/GallerySelection.hooks.d.ts +31 -0
  52. package/dist/media/react/hooks/GallerySelection.hooks.js +32 -0
  53. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +4 -0
  54. package/dist/media/react/hooks/LibraryTab.hooks.js +9 -3
  55. package/dist/media/react/hooks/MediaCard.hooks.d.ts +2 -2
  56. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +2 -2
  57. package/dist/media/react/hooks/MediaPage.hooks.d.ts +4 -0
  58. package/dist/media/react/hooks/MediaPage.hooks.js +10 -13
  59. package/dist/media/react/hooks/MediaPicker.hooks.js +5 -1
  60. package/dist/media/react/hooks/MediaQuery.hooks.d.ts +27 -0
  61. package/dist/media/react/hooks/MediaQuery.hooks.js +41 -0
  62. package/dist/media/react/hooks/ProvidersTab.hooks.js +21 -2
  63. package/dist/media/react/index.d.ts +1 -0
  64. package/dist/media/react/index.js +1 -0
  65. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +5 -0
  66. package/dist/react/components/Sidebar.d.ts +6 -0
  67. package/dist/react/components/Sidebar.js +1 -0
  68. package/dist/react/entities/use-entity-edit.d.ts +1 -1
  69. package/dist/react/hooks/use-sidebar-rail.d.ts +10 -1
  70. package/dist/react/hooks/use-sidebar-rail.js +16 -3
  71. package/dist/seo/adapters/http.d.ts +11 -1
  72. package/dist/seo/adapters/http.js +11 -1
  73. package/dist/seo/controllers/entry-picker.controller.d.ts +2 -0
  74. package/dist/seo/controllers/entry-picker.controller.js +2 -0
  75. package/dist/seo/models.d.ts +8 -0
  76. package/dist/seo/react/components/MediaRefField.js +2 -2
  77. package/dist/seo/react/hooks/MediaRefField.hooks.d.ts +4 -2
  78. package/dist/seo/react/hooks/MediaRefField.hooks.js +8 -3
  79. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +42 -0
  80. package/dist/seo/react/hooks/SeoQuery.hooks.js +65 -0
  81. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -9
  82. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +9 -2
  83. package/dist/seo/react/hooks/use-seo.hooks.js +22 -4
  84. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +19 -0
  85. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +79 -0
  86. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +5 -3
  87. package/dist/seo/react/pages/SeoPage.js +1 -1
  88. package/dist/seo/rules.d.ts +21 -2
  89. package/dist/seo/rules.js +44 -4
  90. package/dist/widgets/react/hooks/WidgetsQuery.hooks.d.ts +37 -0
  91. package/dist/widgets/react/hooks/WidgetsQuery.hooks.js +70 -0
  92. package/dist/widgets/react/hooks/use-widget-instance-editor.hooks.js +5 -3
  93. package/dist/widgets/react/hooks/use-widget-region-editor.hooks.js +3 -1
  94. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +7 -3
  95. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +7 -3
  96. package/package.json +20 -14
@@ -1,12 +1,16 @@
1
+ import { useEffect } from 'react';
2
+ import { useMediaLibraryQuery } from './MediaQuery.hooks.js';
1
3
  import { useController } from '../../../core/react/use-controller.js';
2
4
  import { createLibraryController } from '../../controllers/library.controller.js';
3
5
  import { acceptsMedia } from '../../rules.js';
4
6
  export function useMediaPicker(props, _optional = {}) {
5
- const { controller, snapshot } = useController({ create: () => createLibraryController({ api: props.api }), dependencies: [props.api] }, {
7
+ const cached = useMediaLibraryQuery({ api: props.api });
8
+ const { controller, snapshot } = useController({ create: () => createLibraryController({ api: cached.api }), dependencies: [cached.api] }, {
6
9
  start: ({ controller }) => {
7
10
  void controller.load();
8
11
  },
9
12
  });
13
+ useEffect(() => { controller?.settleLibrary({ ...(cached.query.data ? { all: cached.query.data } : {}), error: cached.query.error }); }, [controller, cached.query.data, cached.query.error]);
10
14
  const actions = (snapshot?.items ?? [])
11
15
  .filter((item) => item.status === 'active' && acceptsMedia({ item, accept: props.accept }))
12
16
  .map((item) => ({ item, onChoose: () => props.onChoose(item) }));
@@ -0,0 +1,27 @@
1
+ import type { MediaApiPort, MediaProvidersPort } from '../../ports.js';
2
+ export declare const MEDIA_QUERY_KEYS: {
3
+ root: readonly ["media"];
4
+ library: readonly ["media", "library"];
5
+ providers: readonly ["media-providers"];
6
+ };
7
+ /** Full-library cache stays separate from the controller's local filtering/counts. Writes
8
+ * invalidate all mounted views, while controller locks retain the accepted-write ordering. */
9
+ export declare function useMediaLibraryQuery({ api }: {
10
+ api: MediaApiPort;
11
+ }, { enabled }?: {
12
+ enabled?: boolean;
13
+ }): {
14
+ api: MediaApiPort;
15
+ query: import("@jini-ai/ui").QueryResult<readonly import("../../models.js").MediaAsset[]>;
16
+ };
17
+ /** null is an unavailable service, never a successful empty catalogue. The shared cache
18
+ * stores only public credential markers returned by the host, never the submitted key text. */
19
+ export declare function useMediaProvidersQuery({ port }: {
20
+ port: MediaProvidersPort | undefined;
21
+ }, { enabled }?: {
22
+ enabled?: boolean;
23
+ }): {
24
+ query: import("@jini-ai/ui").QueryResult<readonly import("../../models.js").MediaProvider[]>;
25
+ loader: import("@jini-ai/ui").CachedLoader<readonly import("../../models.js").MediaProvider[]>;
26
+ };
27
+ //# sourceMappingURL=MediaQuery.hooks.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { useCallback, useMemo, useRef } from 'react';
2
+ import { useCachedLoader, useFetchMutation, useFetchQuery } from '@jini-ai/ui/fetch-query';
3
+ export const MEDIA_QUERY_KEYS = { root: ['media'], library: ['media', 'library'], providers: ['media-providers'] };
4
+ /** Full-library cache stays separate from the controller's local filtering/counts. Writes
5
+ * invalidate all mounted views, while controller locks retain the accepted-write ordering. */
6
+ export function useMediaLibraryQuery({ api }, { enabled = true } = {}) {
7
+ const fetch = useCallback(() => api.list({}), [api]);
8
+ const query = useFetchQuery({ key: MEDIA_QUERY_KEYS.library, fetch }, { enabled });
9
+ const loader = useCachedLoader({ key: MEDIA_QUERY_KEYS.library, fetch }, { staleTime: 0 });
10
+ const uploadMutation = useFetchMutation({ run: ({ input }) => api.upload(...input) }, { invalidates: [MEDIA_QUERY_KEYS.root] });
11
+ const trashMutation = useFetchMutation({ run: ({ input }) => api.trash(...input) }, { invalidates: [MEDIA_QUERY_KEYS.root] });
12
+ const deleteMutation = useFetchMutation({ run: ({ input }) => api.delete(...input) }, { invalidates: [MEDIA_QUERY_KEYS.root] });
13
+ const updateMutation = useFetchMutation({ run: ({ input }) => api.update(...input) }, { invalidates: [MEDIA_QUERY_KEYS.root] });
14
+ const restoreMutation = useFetchMutation({ run: ({ input }) => api.restore(...input) }, { invalidates: [MEDIA_QUERY_KEYS.root] });
15
+ const replaceMutation = useFetchMutation({ run: ({ input }) => api.replace(...input) }, { invalidates: [MEDIA_QUERY_KEYS.root] });
16
+ const current = useRef({ loader, uploadMutation, trashMutation, deleteMutation, updateMutation, restoreMutation, replaceMutation });
17
+ current.current = { loader, uploadMutation, trashMutation, deleteMutation, updateMutation, restoreMutation, replaceMutation };
18
+ const cachedApi = useMemo(() => ({ ...api, list: () => current.current.loader.load(),
19
+ upload: (...input) => current.current.uploadMutation.mutate({ input }),
20
+ trash: (...input) => current.current.trashMutation.mutate({ input }),
21
+ delete: (...input) => current.current.deleteMutation.mutate({ input }),
22
+ update: (...input) => current.current.updateMutation.mutate({ input }),
23
+ ...(api.restore ? { restore: (...input) => current.current.restoreMutation.mutate({ input }) } : {}),
24
+ ...(api.replace ? { replace: (...input) => current.current.replaceMutation.mutate({ input }) } : {}),
25
+ }), [api]);
26
+ return { api: cachedApi, query };
27
+ }
28
+ /** null is an unavailable service, never a successful empty catalogue. The shared cache
29
+ * stores only public credential markers returned by the host, never the submitted key text. */
30
+ export function useMediaProvidersQuery({ port }, { enabled = true } = {}) {
31
+ const fetch = useCallback(async () => {
32
+ const items = await port?.list({});
33
+ if (!items)
34
+ throw new Error('Provider service unavailable');
35
+ return items;
36
+ }, [port]);
37
+ const query = useFetchQuery({ key: MEDIA_QUERY_KEYS.providers, fetch }, { enabled: enabled && !!port });
38
+ const loader = useCachedLoader({ key: MEDIA_QUERY_KEYS.providers, fetch }, { staleTime: 0 });
39
+ return { query, loader };
40
+ }
41
+ //# sourceMappingURL=MediaQuery.hooks.js.map
@@ -1,3 +1,5 @@
1
+ import { useFetchMutation } from '@jini-ai/ui/fetch-query';
2
+ import { MEDIA_QUERY_KEYS, useMediaProvidersQuery } from './MediaQuery.hooks.js';
1
3
  import { useEffect, useMemo, useRef, useState } from 'react';
2
4
  import { useMediaProvidersTab, sortProvidersByConfigured, isEntryPresent, isMarkerOnlyEntry, maskedKeyLabel, isProviderBaseUrlInvalid, invalidBaseUrlProviderIds } from '@jini-ai/ui';
3
5
  import { useMediaPorts } from './MediaPorts.hooks.js';
@@ -10,6 +12,9 @@ function configuration({ items }, _optional = {}) {
10
12
  }
11
13
  export function useProvidersTab(_required = {}, _optional = {}) {
12
14
  const { mediaProviders } = useMediaPorts();
15
+ const cached = useMediaProvidersQuery({ port: mediaProviders });
16
+ const reads = useRef(cached.loader);
17
+ reads.current = cached.loader;
13
18
  const [discoveredCatalog, setDiscoveredCatalog] = useState([]);
14
19
  const [visibleKeys, setVisibleKeys] = useState(() => new Set());
15
20
  const lifetime = useRef(null);
@@ -20,13 +25,13 @@ export function useProvidersTab(_required = {}, _optional = {}) {
20
25
  }, [mediaProviders]);
21
26
  // Always invoke the hook. An unavailable optional port uses an inert, scoped fallback.
22
27
  // Reuse the established editor's send-time serialization, reload merge and clear rollback.
23
- const editorPort = useMemo(() => {
28
+ const rawEditorPort = useMemo(() => {
24
29
  let lastRead = [];
25
30
  return {
26
31
  async fetchMediaProviders() {
27
32
  try {
28
33
  const signal = lifetime.current.signal;
29
- const items = await mediaProviders?.list({}, { signal });
34
+ const items = await reads.current.load();
30
35
  if (!items || signal.aborted)
31
36
  return null;
32
37
  lastRead = items;
@@ -75,6 +80,20 @@ export function useProvidersTab(_required = {}, _optional = {}) {
75
80
  },
76
81
  };
77
82
  }, [mediaProviders]);
83
+ const save = useFetchMutation({ run: async ({ input }) => {
84
+ const result = await rawEditorPort.saveMediaProviders(input);
85
+ return result;
86
+ } }, { invalidates: [MEDIA_QUERY_KEYS.providers] });
87
+ const saves = useRef(save);
88
+ saves.current = save;
89
+ const editorPort = useMemo(() => ({
90
+ fetchMediaProviders: async () => {
91
+ // staleTime: 0 on the loader forces a new settled read on explicit reload, while the
92
+ // initial editor read joins the observer's in-flight request instead of cancelling it.
93
+ return rawEditorPort.fetchMediaProviders();
94
+ },
95
+ saveMediaProviders: (providers) => saves.current.mutate({ input: providers }),
96
+ }), [rawEditorPort]);
78
97
  const editor = useMediaProvidersTab({ port: editorPort });
79
98
  const catalog = mediaProviders?.catalog ?? discoveredCatalog;
80
99
  const pinnedIds = mediaProviders?.pinnedProviderIds ?? [];
@@ -1,3 +1,4 @@
1
+ export { useGallerySelection, type GallerySelectionImage } from './hooks/GallerySelection.hooks.js';
1
2
  import type { ReactNode } from 'react';
2
3
  import type { OverlayPort } from '../../react/overlays.js';
3
4
  import type { MediaApiPort } from '../ports.js';
@@ -1,3 +1,4 @@
1
+ export { useGallerySelection } from './hooks/GallerySelection.hooks.js';
1
2
  import { createElement } from 'react';
2
3
  import { bindReact } from '../../core/react/bind-react.js';
3
4
  import { mediaModule } from '../media.module.js';
@@ -70,6 +70,7 @@ export function isProtectedEmbedElement({ el }) {
70
70
  */
71
71
  const EMBED_KIND_LABELS = {
72
72
  media: 'Media',
73
+ gallery: 'Gallery',
73
74
  widget: 'Widget',
74
75
  post: 'Post',
75
76
  content: 'Content',
@@ -140,6 +141,10 @@ export function describeEmbedPlaceholder({ el }) {
140
141
  if (typeof config.type !== 'string' || config.type === '')
141
142
  return undefined;
142
143
  const type = config.type.toLowerCase();
144
+ if (type === 'gallery') {
145
+ const count = Array.isArray(config.images) ? config.images.length : 0;
146
+ return { kindLabel: EMBED_KIND_LABELS.gallery, identityLabel: count === 0 ? 'no images set' : `${count} ${count === 1 ? 'image' : 'images'}` };
147
+ }
143
148
  return { kindLabel: EMBED_KIND_LABELS[type] ?? titleCase(type), identityLabel: identityLabel(config) };
144
149
  }
145
150
  export function InteractiveHtmlEditor(props) {
@@ -47,6 +47,12 @@ export interface SidebarProps {
47
47
  * @default false
48
48
  */
49
49
  railDefaultCollapsed?: boolean;
50
+ /** Temporary displayed rail state. A boolean controls it without changing the stored preference;
51
+ * null/undefined restores that preference. @example railOverride={dockOpen ? true : null} */
52
+ railOverride?: boolean | null | undefined;
53
+ /** Toggle requests while `railOverride` is active. The host updates its override to accept them;
54
+ * without a callback the controlled rail stays fixed. Uncontrolled toggles persist as usual. */
55
+ onRailCollapsedChange?: ((collapsed: boolean) => void) | undefined;
50
56
  /** Accessible name for the `<nav>` landmark. @default "Admin" */
51
57
  label?: string;
52
58
  /** Extra class names appended to `.cms-nav`. */
@@ -136,6 +136,7 @@ const sidebarStorage = {
136
136
  function SidebarRoot(props) {
137
137
  const rail = useSidebarRail({ storage: sidebarStorage, events: window }, {
138
138
  storageKey: props.railStorageKey, defaultCollapsed: props.railDefaultCollapsed,
139
+ railOverride: props.railOverride, onCollapsedChange: props.onRailCollapsedChange,
139
140
  });
140
141
  const [tooltip, setTooltip] = useState(null);
141
142
  const open = props.open ?? false;
@@ -18,7 +18,7 @@ export declare function useEntityEdit({ port, ...props }: EntityEditProps & {
18
18
  missing: readonly string[];
19
19
  invalid: readonly import("./rules.js").DraftFieldProblem[];
20
20
  canSave: boolean;
21
- pending: "remove" | "save" | null;
21
+ pending: "save" | "remove" | null;
22
22
  failed: boolean;
23
23
  setField: ({ name, value }: {
24
24
  readonly name: string;
@@ -33,13 +33,22 @@ export interface SidebarRail {
33
33
  * (expanded), which is what every existing host gets by not passing it. A stored preference always
34
34
  * takes precedence — see {@link readPersisted} for why absent and `'0'` must stay distinguishable.
35
35
  *
36
+ * @param railOverride A boolean controls the displayed rail without changing its saved preference.
37
+ * Null/undefined releases control and reveals the latest preference, including cross-tab updates.
38
+ * @param onCollapsedChange Receives toggle requests only while an override is active. The host must
39
+ * update its override to accept the request; without this callback the controlled rail stays fixed.
40
+ * @returns Displayed collapse state and a toggle (persisted when uncontrolled, requested otherwise).
41
+ * @example useSidebarRail({ storage, events }, { railOverride: true, onCollapsedChange: setOverride })
42
+ *
36
43
  * @complexity O(1) — one state read/write per toggle, no derived computation.
37
44
  */
38
45
  export declare function useSidebarRail({ storage, events }: {
39
46
  readonly storage: Pick<Storage, 'getItem' | 'setItem'>;
40
47
  readonly events: Pick<Window, 'addEventListener' | 'removeEventListener'>;
41
- }, { storageKey, defaultCollapsed }?: {
48
+ }, { storageKey, defaultCollapsed, railOverride, onCollapsedChange }?: {
42
49
  readonly storageKey?: string | undefined;
43
50
  readonly defaultCollapsed?: boolean | undefined;
51
+ readonly railOverride?: boolean | null | undefined;
52
+ readonly onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
44
53
  }): SidebarRail;
45
54
  //# sourceMappingURL=use-sidebar-rail.d.ts.map
@@ -62,17 +62,30 @@ function writePersisted(storageKey, collapsed, storage) {
62
62
  * (expanded), which is what every existing host gets by not passing it. A stored preference always
63
63
  * takes precedence — see {@link readPersisted} for why absent and `'0'` must stay distinguishable.
64
64
  *
65
+ * @param railOverride A boolean controls the displayed rail without changing its saved preference.
66
+ * Null/undefined releases control and reveals the latest preference, including cross-tab updates.
67
+ * @param onCollapsedChange Receives toggle requests only while an override is active. The host must
68
+ * update its override to accept the request; without this callback the controlled rail stays fixed.
69
+ * @returns Displayed collapse state and a toggle (persisted when uncontrolled, requested otherwise).
70
+ * @example useSidebarRail({ storage, events }, { railOverride: true, onCollapsedChange: setOverride })
71
+ *
65
72
  * @complexity O(1) — one state read/write per toggle, no derived computation.
66
73
  */
67
- export function useSidebarRail({ storage, events }, { storageKey = DEFAULT_SIDEBAR_RAIL_STORAGE_KEY, defaultCollapsed = false } = {}) {
74
+ export function useSidebarRail({ storage, events }, { storageKey = DEFAULT_SIDEBAR_RAIL_STORAGE_KEY, defaultCollapsed = false, railOverride, onCollapsedChange } = {}) {
68
75
  const [collapsed, setCollapsed] = useState(() => readPersisted(storageKey, storage) ?? defaultCollapsed);
69
76
  const toggle = useCallback(() => {
77
+ // A temporary host layout choice must not become the operator's saved preference. Request
78
+ // changes outside the state updater so React's StrictMode replay cannot notify the host twice.
79
+ if (railOverride != null) {
80
+ onCollapsedChange?.(!railOverride);
81
+ return;
82
+ }
70
83
  setCollapsed((current) => {
71
84
  const next = !current;
72
85
  writePersisted(storageKey, next, storage);
73
86
  return next;
74
87
  });
75
- }, [storageKey, storage]);
88
+ }, [storageKey, storage, railOverride, onCollapsedChange]);
76
89
  /** Cross-tab sync: two admin tabs open side by side should agree on the rail state rather than
77
90
  * silently diverging the moment either one is toggled. */
78
91
  useEffect(() => {
@@ -88,6 +101,6 @@ export function useSidebarRail({ storage, events }, { storageKey = DEFAULT_SIDEB
88
101
  events.addEventListener('storage', onStorage);
89
102
  return () => events.removeEventListener('storage', onStorage);
90
103
  }, [storageKey, defaultCollapsed, events]);
91
- return { collapsed, toggle };
104
+ return { collapsed: railOverride ?? collapsed, toggle };
92
105
  }
93
106
  //# sourceMappingURL=use-sidebar-rail.js.map
@@ -21,11 +21,21 @@ export interface SeoSitemapTransportPort {
21
21
  signal?: AbortSignal;
22
22
  }): Promise<Pick<Response, 'ok' | 'status' | 'headers' | 'text'>>;
23
23
  }
24
+ /** Host supplies raw HEAD I/O; no browser globals, auth state or image implementation here. */
25
+ export interface SeoImageTransportPort {
26
+ head(required: {
27
+ url: string;
28
+ }, optional?: {
29
+ signal?: AbortSignal;
30
+ }): Promise<Pick<Response, 'status' | 'headers'>>;
31
+ }
24
32
  /** Preserves every existing SEO request path and body; basePath is the host workspace path. */
25
33
  export declare function createHttpSeoApi({ transport, basePath }: {
26
34
  transport: SeoTransportPort;
27
35
  basePath: string;
28
- }, { sitemapTransport }?: {
36
+ }, { sitemapTransport, imageTransport, siteOrigin }?: {
29
37
  sitemapTransport?: SeoSitemapTransportPort;
38
+ imageTransport?: SeoImageTransportPort;
39
+ siteOrigin?: string | undefined;
30
40
  }): AdminSeoPort;
31
41
  //# sourceMappingURL=http.d.ts.map
@@ -1,5 +1,6 @@
1
+ import { resolveMediaRefPreviewUrl } from '../rules.js';
1
2
  /** Preserves every existing SEO request path and body; basePath is the host workspace path. */
2
- export function createHttpSeoApi({ transport, basePath }, { sitemapTransport } = {}) {
3
+ export function createHttpSeoApi({ transport, basePath }, { sitemapTransport, imageTransport, siteOrigin } = {}) {
3
4
  const base = basePath.replace(/\/$/, '');
4
5
  const entryPath = (entryId) => `${base}/seo/entries/${encodeURIComponent(entryId)}`;
5
6
  async function data(path, method, body) {
@@ -20,6 +21,15 @@ export function createHttpSeoApi({ transport, basePath }, { sitemapTransport } =
20
21
  listSeoPosts: () => choices('posts'),
21
22
  listSeoPages: () => choices('pages'),
22
23
  mediaOriginalUrl: ({ id }) => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }),
24
+ mediaPreviewUrl: ({ value }) => resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }) }, { siteOrigin }),
25
+ ...(imageTransport ? { async inspectShareImage({ url }, options = {}) {
26
+ options.signal?.throwIfAborted();
27
+ const response = await imageTransport.head({ url }, options);
28
+ // Number(null) is zero: an omitted header must not accuse a healthy image of being empty.
29
+ const raw = response.headers.get('content-length');
30
+ const length = raw === null || raw.trim() === '' ? NaN : Number(raw);
31
+ return { status: response.status, byteSize: Number.isSafeInteger(length) && length >= 0 ? length : null };
32
+ } } : {}),
23
33
  async fetchSitemapXml(_required, options = {}) {
24
34
  if (!sitemapTransport)
25
35
  throw new Error('SEO sitemap transport unavailable');
@@ -1,6 +1,8 @@
1
1
  import type { AdminSeoPort } from '../ports.js';
2
2
  import type { EntryPickerController, SeoTranslator, SeoErrorFormatter } from '../models.js';
3
3
  /** Two existing reads, combined posts first then pages; no new content repository. */
4
+ /** @deprecated React entry picking uses the shared query owner in use-entry-picker.hooks.
5
+ * Retained for headless package consumers until an owner-approved export removal. */
4
6
  export declare function createEntryPickerController({ api }: {
5
7
  api: AdminSeoPort;
6
8
  }, { t, describeError }?: {
@@ -1,6 +1,8 @@
1
1
  import { createControllerStore } from '../../core/module/controller-store.js';
2
2
  import { initialEntryPickerState, describeSeoError } from '../models.js';
3
3
  /** Two existing reads, combined posts first then pages; no new content repository. */
4
+ /** @deprecated React entry picking uses the shared query owner in use-entry-picker.hooks.
5
+ * Retained for headless package consumers until an owner-approved export removal. */
4
6
  export function createEntryPickerController({ api }, { t = key => key, describeError = describeSeoError } = {}) {
5
7
  const store = createControllerStore({ initial: initialEntryPickerState });
6
8
  async function load(_required = {}, _optional = {}) {
@@ -154,6 +154,14 @@ export interface MediaRefFieldController {
154
154
  /** The field's current value resolved to a thumbnail `<img src>`, or `null` when the value is
155
155
  * empty or unparseable — see `resolveMediaRefPreviewUrl`'s own doc. */
156
156
  previewUrl: string | null;
157
+ /** Shared diagnostics for defaults, OG and Twitter fields; optional for injected legacy views. */
158
+ warning?: string | null;
159
+ onPreviewLoad?: (event: {
160
+ currentTarget: HTMLImageElement;
161
+ }) => void;
162
+ onPreviewError?: (event: {
163
+ currentTarget: HTMLImageElement;
164
+ }) => void;
157
165
  /** `MediaPickerDialog`'s `accept` — images only: every field this backs is an og:image /
158
166
  * twitter:image, and a crawler cannot use a video there (2026-10-05, same filter the post
159
167
  * featured-image chooser passes). */
@@ -6,7 +6,7 @@ import { useWiredMediaRefField, useMediaRefPresentation, handleSpread } from "..
6
6
  export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField = useWiredMediaRefField, }, _optional = {}) {
7
7
  const t = useSeoCopy();
8
8
  const { Picker = MediaPickerBridge, storedReference } = useMediaRefPresentation({ value });
9
- const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, accept } = useField({ value, onChange });
10
- return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: id, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", children: [_jsx("input", { className: "jini-input", id: id, name: storedReference ? undefined : name, value: storedReference ? t({ locale, key: "Selected image" }) : value, readOnly: storedReference, onChange: (e) => onChange(e.target.value), ...(base ? agentHandle({ handle: base }, { role: "field", label }) : {}) }), storedReference && name ? _jsx("input", { type: "hidden", name: name, value: value }) : null, _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale: locale, key: "Choose image" }) }), value ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale: locale, key: "Remove" }) })) : null] }), previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview` }) : null, pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
9
+ const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, accept, warning, onPreviewLoad, onPreviewError } = useField({ value, onChange });
10
+ return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: id, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", children: [_jsx("input", { className: "jini-input", id: id, name: storedReference ? undefined : name, value: storedReference ? t({ locale, key: "Selected image" }) : value, readOnly: storedReference, "aria-describedby": warning ? `${id}-warning` : undefined, onChange: (e) => onChange(e.target.value), ...(base ? agentHandle({ handle: base }, { role: "field", label }) : {}) }), storedReference && name ? _jsx("input", { type: "hidden", name: name, value: value }) : null, _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale: locale, key: "Choose image" }) }), value ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale: locale, key: "Remove" }) })) : null] }), previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, warning ? _jsx("p", { id: `${id}-warning`, className: "jini-notice jini-notice-warning", role: "status", children: warning }) : null, pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
11
11
  }
12
12
  //# sourceMappingURL=MediaRefField.js.map
@@ -1,11 +1,13 @@
1
- import type { MediaRefFieldController } from '../../models.js';
1
+ import type { MediaRefFieldController, SeoTranslator } from '../../models.js';
2
2
  import type { AdminSeoPort } from '../../ports.js';
3
3
  export type { MediaRefFieldController } from '../../models.js';
4
4
  export declare function useMediaRefField({ value, onChange, api }: {
5
5
  value: string;
6
6
  onChange: (value: string) => void;
7
7
  api: AdminSeoPort;
8
- }, _optional?: Record<string, never>): MediaRefFieldController;
8
+ }, { t }?: {
9
+ t?: SeoTranslator | undefined;
10
+ }): MediaRefFieldController;
9
11
  export declare function useWiredMediaRefField({ value, onChange }: {
10
12
  value: string;
11
13
  onChange: (value: string) => void;
@@ -1,23 +1,28 @@
1
1
  import { useRef } from 'react';
2
+ import { useShareImageWarning } from './use-share-image-warning.hooks.js';
2
3
  import { agentHandle } from '@jini-ai/agentic';
3
4
  import { useController } from '../../../core/react/use-controller.js';
4
5
  import { createMediaRefController } from '../../controllers/media-ref.controller.js';
5
6
  import { isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
6
7
  import { useSeoPorts, useSeoOptions } from './SeoPorts.hooks.js';
7
- export function useMediaRefField({ value, onChange, api }, _optional = {}) {
8
+ export function useMediaRefField({ value, onChange, api }, { t } = {}) {
8
9
  const change = useRef(onChange);
9
10
  change.current = onChange;
10
11
  const { controller, snapshot } = useController({ create: () => createMediaRefController({ onChange: value => change.current(value) }), dependencies: [] });
12
+ const previewUrl = api.mediaPreviewUrl ? api.mediaPreviewUrl({ value }) : resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => api.mediaOriginalUrl({ id }) });
13
+ const diagnostics = useShareImageWarning({ value, previewUrl, api }, { t });
11
14
  return {
15
+ ...diagnostics,
12
16
  pickerOpen: snapshot?.pickerOpen ?? false,
13
17
  openPicker: () => controller?.openPicker({}), closePicker: () => controller?.closePicker({}),
14
18
  handleSelect: item => controller?.handleSelect({ item }), clear: () => controller?.clear({}),
15
- previewUrl: resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => api.mediaOriginalUrl({ id }) }), accept: ['image/*'],
19
+ previewUrl, accept: ['image/*'],
16
20
  };
17
21
  }
18
22
  export function useWiredMediaRefField({ value, onChange }, _optional = {}) {
19
23
  const { seoApi: api } = useSeoPorts();
20
- return useMediaRefField({ value, onChange, api });
24
+ const { t } = useSeoOptions();
25
+ return useMediaRefField({ value, onChange, api }, { t });
21
26
  }
22
27
  /** Slot or existing promise picker; no picker implementation is forked into SEO. */
23
28
  export function useMediaRefPresentation({ value }, _optional = {}) {
@@ -0,0 +1,42 @@
1
+ import { type SeoTranslator } from '../../models.js';
2
+ import type { AdminSeoPort } from '../../ports.js';
3
+ export declare const SEO_KEYS: {
4
+ settings: readonly ["seo", "settings"];
5
+ entries: readonly ["seo", "entry"];
6
+ entry: ({ entryId }: {
7
+ entryId: string;
8
+ }, _optional?: Record<string, never>) => readonly ["seo", "entry", string];
9
+ choices: readonly ["seo", "choices"];
10
+ sitemap: readonly ["seo", "sitemap"];
11
+ };
12
+ /** Controllers keep their draft/error contracts; these injected ports share the provider cache.
13
+ * Reads omit a controller's abort signal because another mounted consumer can own the same read.
14
+ * Controller generations/disposal still prevent that consumer from settling an obsolete screen. */
15
+ export declare function useSeoSettingsQuery({ api }: {
16
+ api: AdminSeoPort;
17
+ }, { t }?: {
18
+ t?: SeoTranslator;
19
+ }): {
20
+ api: AdminSeoPort;
21
+ query: import("@jini-ai/ui").QueryResult<import("../../../core/index.js").AdminSeoSettings>;
22
+ };
23
+ export declare function useSeoEntryQuery({ api, entryId }: {
24
+ api: AdminSeoPort;
25
+ entryId: string;
26
+ }, _optional?: Record<string, never>): {
27
+ api: AdminSeoPort;
28
+ query: import("@jini-ai/ui").QueryResult<{
29
+ resolved: import("../../../core/index.js").AdminSeoMeta;
30
+ analysis: import("../../../core/index.js").AdminSeoAnalysis;
31
+ }>;
32
+ };
33
+ export declare function useSitemapQuery({ api, enabled }: {
34
+ api: AdminSeoPort;
35
+ enabled: boolean;
36
+ }, _optional?: Record<string, never>): {
37
+ api: AdminSeoPort;
38
+ query: import("@jini-ai/ui").QueryResult<{
39
+ text: string;
40
+ }>;
41
+ };
42
+ //# sourceMappingURL=SeoQuery.hooks.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { useCallback, useMemo, useRef } from 'react';
2
+ import { useCachedLoader, useFetchMutation, useFetchQuery } from '@jini-ai/ui/fetch-query';
3
+ import { describeSeoError } from '../../models.js';
4
+ export const SEO_KEYS = {
5
+ settings: ['seo', 'settings'],
6
+ entries: ['seo', 'entry'],
7
+ entry: ({ entryId }, _optional = {}) => ['seo', 'entry', entryId],
8
+ choices: ['seo', 'choices'],
9
+ sitemap: ['seo', 'sitemap'],
10
+ };
11
+ /** Controllers keep their draft/error contracts; these injected ports share the provider cache.
12
+ * Reads omit a controller's abort signal because another mounted consumer can own the same read.
13
+ * Controller generations/disposal still prevent that consumer from settling an obsolete screen. */
14
+ export function useSeoSettingsQuery({ api }, { t = key => key } = {}) {
15
+ const translator = useRef(t);
16
+ translator.current = t;
17
+ const fetch = useCallback(async () => {
18
+ try {
19
+ return await api.getSeoSettings({});
20
+ }
21
+ catch (error) {
22
+ throw new Error(describeSeoError({ error, fallback: translator.current('failed to load SEO settings') }));
23
+ }
24
+ }, [api]);
25
+ const query = useFetchQuery({ key: SEO_KEYS.settings, fetch }, { staleTime: 0 });
26
+ const loader = useCachedLoader({ key: SEO_KEYS.settings, fetch }, { staleTime: 0 });
27
+ const save = useFetchMutation({ run: ({ input }) => api.putSeoSettings(...input) }, { invalidates: [SEO_KEYS.settings, SEO_KEYS.entries] });
28
+ const regenerate = useFetchMutation({ run: ({ input }) => api.regenerateSeoSitemap(...input) }, { invalidates: [SEO_KEYS.sitemap] });
29
+ const current = useRef({ loader, save, regenerate });
30
+ current.current = { loader, save, regenerate };
31
+ const cachedApi = useMemo(() => ({ ...api,
32
+ getSeoSettings: () => current.current.loader.load(),
33
+ putSeoSettings: (...input) => current.current.save.mutate({ input }),
34
+ regenerateSeoSitemap: (...input) => current.current.regenerate.mutate({ input }),
35
+ }), [api]);
36
+ return { api: cachedApi, query };
37
+ }
38
+ export function useSeoEntryQuery({ api, entryId }, _optional = {}) {
39
+ const key = useMemo(() => SEO_KEYS.entry({ entryId }), [entryId]);
40
+ const fetch = useCallback(async () => {
41
+ const [resolved, analysis] = await Promise.all([api.getSeoEntry({ entryId }), api.analyzeSeoEntry({ entryId })]);
42
+ return { resolved, analysis };
43
+ }, [api, entryId]);
44
+ const query = useFetchQuery({ key, fetch }, { enabled: !!entryId, staleTime: 0 });
45
+ const loader = useCachedLoader({ key, fetch }, { staleTime: 0 });
46
+ const save = useFetchMutation({ run: ({ input }) => api.putSeoEntry(...input) }, { invalidates: [SEO_KEYS.entries] });
47
+ const current = useRef({ loader, save });
48
+ current.current = { loader, save };
49
+ const cachedApi = useMemo(() => ({ ...api,
50
+ getSeoEntry: async () => (await current.current.loader.load()).resolved,
51
+ analyzeSeoEntry: async () => (await current.current.loader.load()).analysis,
52
+ putSeoEntry: (...input) => current.current.save.mutate({ input }),
53
+ }), [api, entryId]);
54
+ return { api: cachedApi, query };
55
+ }
56
+ export function useSitemapQuery({ api, enabled }, _optional = {}) {
57
+ const fetch = useCallback(() => api.fetchSitemapXml({}), [api]);
58
+ const query = useFetchQuery({ key: SEO_KEYS.sitemap, fetch }, { enabled });
59
+ const loader = useCachedLoader({ key: SEO_KEYS.sitemap, fetch });
60
+ const current = useRef(loader);
61
+ current.current = loader;
62
+ const cachedApi = useMemo(() => ({ ...api, fetchSitemapXml: () => current.current.load() }), [api]);
63
+ return { api: cachedApi, query };
64
+ }
65
+ //# sourceMappingURL=SeoQuery.hooks.js.map
@@ -1,16 +1,14 @@
1
1
  import { describeSeoError } from '../../models.js';
2
- import { useRef } from 'react';
3
- import { useController } from '../../../core/react/use-controller.js';
4
- import { createEntryPickerController } from '../../controllers/entry-picker.controller.js';
2
+ import { useFetchQuery } from '@jini-ai/ui/fetch-query';
3
+ import { SEO_KEYS } from './SeoQuery.hooks.js';
5
4
  import { initialEntryPickerState } from '../../models.js';
6
5
  import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
7
6
  export function useEntryPicker({ api }, { t, describeError } = {}) {
8
- const translator = useRef(t);
9
- translator.current = t;
10
- const formatter = useRef(describeError);
11
- formatter.current = describeError;
12
- const { snapshot } = useController({ create: () => createEntryPickerController({ api }, { t: (key, vars) => translator.current?.(key, vars) ?? key, describeError: required => formatter.current ? formatter.current(required) : describeSeoError(required) }), dependencies: [api] }, { start: ({ controller }) => { void controller.load({}); } });
13
- return snapshot ?? initialEntryPickerState;
7
+ const query = useFetchQuery({ key: SEO_KEYS.choices, fetch: async () => {
8
+ const [posts, pages] = await Promise.all([api.listSeoPosts({}), api.listSeoPages({})]);
9
+ return [...posts, ...pages];
10
+ } });
11
+ return { ...initialEntryPickerState, entries: query.data ?? null, error: query.error ? (describeError ?? describeSeoError)({ error: query.error, fallback: t?.('failed to load entries') ?? 'failed to load entries' }) : null };
14
12
  }
15
13
  export function useWiredEntryPicker(_required = {}, _optional = {}) {
16
14
  const { seoApi: api } = useSeoPorts();
@@ -1,15 +1,22 @@
1
1
  import { describeSeoError } from '../../models.js';
2
- import { useRef } from 'react';
2
+ import { useEffect, useRef } from 'react';
3
+ import { useSeoEntryQuery } from './SeoQuery.hooks.js';
3
4
  import { useController } from '../../../core/react/use-controller.js';
4
5
  import { createSeoEntryController } from '../../controllers/seo-entry.controller.js';
5
6
  import { initialSeoEntryState } from '../../models.js';
6
7
  import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
7
8
  export function useSeoEntryPanel({ api, entryId }, { t, describeError } = {}) {
9
+ const cached = useSeoEntryQuery({ api, entryId });
8
10
  const translator = useRef(t);
9
11
  translator.current = t;
10
12
  const formatter = useRef(describeError);
11
13
  formatter.current = describeError;
12
- const { controller, snapshot } = useController({ create: () => createSeoEntryController({ api, entryId }, { t: (key, vars) => translator.current?.(key, vars) ?? key, describeError: required => formatter.current ? formatter.current(required) : describeSeoError(required) }), dependencies: [api, entryId] }, { start: ({ controller }) => { void controller.load({}); } });
14
+ const { controller, snapshot } = useController({ create: () => createSeoEntryController({ api: cached.api, entryId }, { t: (key, vars) => translator.current?.(key, vars) ?? key, describeError: required => formatter.current ? formatter.current(required) : describeSeoError(required) }), dependencies: [cached.api, entryId] }, { start: ({ controller }) => { void controller.load({}); } });
15
+ useEffect(() => {
16
+ // Revalidation updates server truth without discarding touched override fields.
17
+ if (controller && cached.query.data)
18
+ controller.set({ patch: cached.query.data });
19
+ }, [controller, cached.query.data]);
13
20
  const state = snapshot ?? initialSeoEntryState;
14
21
  return {
15
22
  ...state,