@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.
- package/dist/agent-plugins/package-file-tree.d.ts +3 -1
- package/dist/agent-plugins/package-file-tree.js +7 -7
- package/dist/comments/controllers/comment-queue.controller.d.ts +5 -1
- package/dist/comments/controllers/comment-queue.controller.js +2 -2
- package/dist/comments/react/hooks/use-comment-queue.hooks.js +14 -3
- package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -0
- package/dist/consent/react/ConsentSettingsPanel.js +24 -0
- package/dist/consent/react/index.d.ts +3 -0
- package/dist/consent/react/index.js +2 -0
- package/dist/consent/react/settings-panel.hooks.d.ts +19 -0
- package/dist/consent/react/settings-panel.hooks.js +30 -0
- package/dist/core/ports/forms.d.ts +23 -3
- package/dist/core/ports/forms.js +3 -3
- package/dist/core/ports/seo.d.ts +17 -0
- package/dist/forms/adapters/http.d.ts +5 -2
- package/dist/forms/adapters/http.js +7 -2
- package/dist/forms/controllers/field-attributes-dialog.controller.d.ts +59 -1
- package/dist/forms/controllers/field-attributes-dialog.controller.js +27 -2
- package/dist/forms/field-settings.d.ts +142 -0
- package/dist/forms/field-settings.js +327 -0
- package/dist/forms/form-editor-dirty.d.ts +42 -0
- package/dist/forms/form-editor-dirty.js +25 -0
- package/dist/forms/forms.module.d.ts +72 -0
- package/dist/forms/html-rules.d.ts +41 -3
- package/dist/forms/html-rules.js +118 -17
- package/dist/forms/messages.en.d.ts +72 -0
- package/dist/forms/messages.en.js +73 -0
- package/dist/forms/react/components/FieldAttributesDialog.js +20 -19
- package/dist/forms/react/components/FieldSettingsSection.d.ts +19 -0
- package/dist/forms/react/components/FieldSettingsSection.js +21 -0
- package/dist/forms/react/components/FormFieldsEditor.js +7 -8
- package/dist/forms/react/components/FormSubmissionDetail.js +5 -2
- package/dist/forms/react/hooks/forms-view.hooks.d.ts +75 -5
- package/dist/forms/react/hooks/forms-view.hooks.js +46 -2
- package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.d.ts +10 -0
- package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +12 -2
- package/dist/forms/react/hooks/use-form-editor.hooks.d.ts +11 -0
- package/dist/forms/react/hooks/use-form-editor.hooks.js +25 -2
- package/dist/forms/react/hooks/use-form-fields-editor.hooks.d.ts +2 -0
- package/dist/forms/react/hooks/use-form-fields-editor.hooks.js +5 -1
- package/dist/forms/react/hooks/use-form-submission-detail.hooks.d.ts +4 -0
- package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -0
- package/dist/forms/react/index.d.ts +216 -0
- package/dist/forms/react/pages/FormEditor.js +3 -2
- package/dist/forms/rules.d.ts +10 -1
- package/dist/forms/rules.js +8 -1
- package/dist/media/controllers/library.controller.d.ts +4 -0
- package/dist/media/controllers/library.controller.js +13 -0
- package/dist/media/react/components/MediaPicker.js +2 -2
- package/dist/media/react/hooks/EditMediaPanel.hooks.js +4 -2
- package/dist/media/react/hooks/GallerySelection.hooks.d.ts +31 -0
- package/dist/media/react/hooks/GallerySelection.hooks.js +32 -0
- package/dist/media/react/hooks/LibraryTab.hooks.d.ts +4 -0
- package/dist/media/react/hooks/LibraryTab.hooks.js +9 -3
- package/dist/media/react/hooks/MediaCard.hooks.d.ts +2 -2
- package/dist/media/react/hooks/MediaGrid.hooks.d.ts +2 -2
- package/dist/media/react/hooks/MediaPage.hooks.d.ts +4 -0
- package/dist/media/react/hooks/MediaPage.hooks.js +10 -13
- package/dist/media/react/hooks/MediaPicker.hooks.js +5 -1
- package/dist/media/react/hooks/MediaQuery.hooks.d.ts +27 -0
- package/dist/media/react/hooks/MediaQuery.hooks.js +41 -0
- package/dist/media/react/hooks/ProvidersTab.hooks.js +21 -2
- package/dist/media/react/index.d.ts +1 -0
- package/dist/media/react/index.js +1 -0
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +5 -0
- package/dist/react/components/Sidebar.d.ts +6 -0
- package/dist/react/components/Sidebar.js +1 -0
- package/dist/react/entities/use-entity-edit.d.ts +1 -1
- package/dist/react/hooks/use-sidebar-rail.d.ts +10 -1
- package/dist/react/hooks/use-sidebar-rail.js +16 -3
- package/dist/seo/adapters/http.d.ts +11 -1
- package/dist/seo/adapters/http.js +11 -1
- package/dist/seo/controllers/entry-picker.controller.d.ts +2 -0
- package/dist/seo/controllers/entry-picker.controller.js +2 -0
- package/dist/seo/models.d.ts +8 -0
- package/dist/seo/react/components/MediaRefField.js +2 -2
- package/dist/seo/react/hooks/MediaRefField.hooks.d.ts +4 -2
- package/dist/seo/react/hooks/MediaRefField.hooks.js +8 -3
- package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +42 -0
- package/dist/seo/react/hooks/SeoQuery.hooks.js +65 -0
- package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -9
- package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +9 -2
- package/dist/seo/react/hooks/use-seo.hooks.js +22 -4
- package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +19 -0
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +79 -0
- package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +5 -3
- package/dist/seo/react/pages/SeoPage.js +1 -1
- package/dist/seo/rules.d.ts +21 -2
- package/dist/seo/rules.js +44 -4
- package/dist/widgets/react/hooks/WidgetsQuery.hooks.d.ts +37 -0
- package/dist/widgets/react/hooks/WidgetsQuery.hooks.js +70 -0
- package/dist/widgets/react/hooks/use-widget-instance-editor.hooks.js +5 -3
- package/dist/widgets/react/hooks/use-widget-region-editor.hooks.js +3 -1
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +7 -3
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +7 -3
- package/package.json +20 -14
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useInvalidate } from '@jini-ai/ui/fetch-query';
|
|
3
|
+
import { SEO_KEYS, useSeoSettingsQuery } from './SeoQuery.hooks.js';
|
|
2
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
3
5
|
import { createSeoController } from '../../controllers/seo.controller.js';
|
|
4
6
|
import { initialSeoState } from '../../models.js';
|
|
5
7
|
import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
|
|
6
8
|
export function useSeo({ api }, { t, events } = {}) {
|
|
9
|
+
const cached = useSeoSettingsQuery({ api }, { ...(t ? { t } : {}) });
|
|
10
|
+
const invalidate = useInvalidate();
|
|
7
11
|
const translator = useRef(t);
|
|
8
12
|
translator.current = t;
|
|
9
|
-
const { controller, snapshot } = useController({ create: () => createSeoController({ api }, { t: (key, vars) => translator.current?.(key, vars) ?? key }), dependencies: [api] }, {
|
|
13
|
+
const { controller, snapshot } = useController({ create: () => createSeoController({ api: cached.api }, { t: (key, vars) => translator.current?.(key, vars) ?? key }), dependencies: [cached.api] }, {
|
|
14
|
+
// Uncontrolled form fields must first mount with this open's fresh settings. The loader
|
|
15
|
+
// joins the query's mount read; retained data would permanently fill defaultValue too early.
|
|
16
|
+
start: ({ controller }) => { void controller.load({}); },
|
|
17
|
+
});
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!controller || !cached.query.data)
|
|
20
|
+
return;
|
|
21
|
+
const state = controller.getSnapshot();
|
|
22
|
+
if (!state.settings)
|
|
23
|
+
return;
|
|
24
|
+
// Shared revalidation updates server truth while an unsaved image choice stays local.
|
|
25
|
+
const imageUntouched = state.defaultOgImage === (state.settings?.defaultOgImage ?? '');
|
|
26
|
+
controller.set({ patch: { settings: cached.query.data, ...(imageUntouched ? { defaultOgImage: cached.query.data.defaultOgImage ?? '' } : {}) } });
|
|
27
|
+
}, [controller, cached.query.data]);
|
|
10
28
|
useEffect(() => {
|
|
11
29
|
if (!controller || !events)
|
|
12
30
|
return;
|
|
13
|
-
return events.subscribe({ onRefresh: () => {
|
|
14
|
-
}, [controller, events]);
|
|
31
|
+
return events.subscribe({ onRefresh: () => { invalidate({ key: SEO_KEYS.settings }); } });
|
|
32
|
+
}, [controller, events, invalidate]);
|
|
15
33
|
return {
|
|
16
|
-
...(snapshot ?? initialSeoState), locale: 'en',
|
|
34
|
+
...(snapshot ?? initialSeoState), error: cached.query.error?.message ?? snapshot?.error ?? null, locale: 'en',
|
|
17
35
|
save: async (patch) => { await controller?.save({ patch }); },
|
|
18
36
|
setDefaultOgImage: value => controller?.setDefaultOgImage({ value }),
|
|
19
37
|
regenerateSitemap: async () => await controller?.regenerateSitemap({}) ?? false,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AdminSeoPort } from '../../ports.js';
|
|
2
|
+
import type { MediaRefFieldController, SeoTranslator } from '../../models.js';
|
|
3
|
+
/** Probe original file size through the host port; decoded width comes from the existing preview.
|
|
4
|
+
* Unknown size is not zero. URL-keyed state and cancellation prevent a prior choice's response
|
|
5
|
+
* or image event from warning against the current selection. The probe has a 10-second bound.
|
|
6
|
+
* External HEAD responses need CORS, so only same-origin files are probed; decoded previews
|
|
7
|
+
* still report external loading failures and known widths. HTTP copy is translated at render.
|
|
8
|
+
* @param required Controlled reference, source preview URL and injected SEO API.
|
|
9
|
+
* @returns Non-blocking warning and the preview's native load/error handlers.
|
|
10
|
+
* @complexity O(1) state and one HEAD per selection; no image bytes are downloaded by the probe.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useShareImageWarning({ value, previewUrl, api }: {
|
|
13
|
+
value: string;
|
|
14
|
+
previewUrl: string | null;
|
|
15
|
+
api: AdminSeoPort;
|
|
16
|
+
}, { t }?: {
|
|
17
|
+
t?: SeoTranslator | undefined;
|
|
18
|
+
}): Pick<MediaRefFieldController, 'warning' | 'onPreviewLoad' | 'onPreviewError'>;
|
|
19
|
+
//# sourceMappingURL=use-share-image-warning.hooks.d.ts.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { interpolate } from '@jini-ai/ui/panel-kit';
|
|
3
|
+
import { shareImageWarning } from '../../rules.js';
|
|
4
|
+
/** Probe original file size through the host port; decoded width comes from the existing preview.
|
|
5
|
+
* Unknown size is not zero. URL-keyed state and cancellation prevent a prior choice's response
|
|
6
|
+
* or image event from warning against the current selection. The probe has a 10-second bound.
|
|
7
|
+
* External HEAD responses need CORS, so only same-origin files are probed; decoded previews
|
|
8
|
+
* still report external loading failures and known widths. HTTP copy is translated at render.
|
|
9
|
+
* @param required Controlled reference, source preview URL and injected SEO API.
|
|
10
|
+
* @returns Non-blocking warning and the preview's native load/error handlers.
|
|
11
|
+
* @complexity O(1) state and one HEAD per selection; no image bytes are downloaded by the probe.
|
|
12
|
+
*/
|
|
13
|
+
export function useShareImageWarning({ value, previewUrl, api }, { t = key => key } = {}) {
|
|
14
|
+
const currentUrl = useRef(previewUrl);
|
|
15
|
+
currentUrl.current = previewUrl;
|
|
16
|
+
const [decoded, setDecoded] = useState(null);
|
|
17
|
+
const [inspection, setInspection] = useState(null);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const inspect = api.inspectShareImage;
|
|
20
|
+
if (!previewUrl || !inspect)
|
|
21
|
+
return;
|
|
22
|
+
// Browser origin, rather than the public site's alias: a cross-origin HEAD cannot establish
|
|
23
|
+
// that an otherwise loadable external image is missing when the server omits CORS headers.
|
|
24
|
+
const location = globalThis.location;
|
|
25
|
+
if (!location)
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
if (new URL(previewUrl, location.href).origin !== location.origin)
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
let active = true;
|
|
36
|
+
const timeout = setTimeout(() => {
|
|
37
|
+
active = false;
|
|
38
|
+
controller.abort();
|
|
39
|
+
setInspection({ url: previewUrl, error: { key: 'The share image check timed out after 10 seconds.' } });
|
|
40
|
+
}, 10_000);
|
|
41
|
+
inspect.call(api, { url: previewUrl }, { signal: controller.signal }).then(({ status, byteSize }) => {
|
|
42
|
+
if (!active)
|
|
43
|
+
return;
|
|
44
|
+
if (status === 404 || status === 410) {
|
|
45
|
+
setInspection({ url: previewUrl, error: { key: 'The share image is missing (HTTP {status}).', vars: { status } } });
|
|
46
|
+
}
|
|
47
|
+
else if (status < 200 || status >= 300) {
|
|
48
|
+
setInspection({ url: previewUrl, error: { key: 'The share image could not be checked (HTTP {status}).', vars: { status } } });
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
setInspection({ url: previewUrl, byteSize });
|
|
52
|
+
}
|
|
53
|
+
}).catch((error) => {
|
|
54
|
+
if (!active)
|
|
55
|
+
return;
|
|
56
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
57
|
+
setInspection({ url: previewUrl, error: { key: 'The share image could not be checked: {detail}', vars: { detail } } });
|
|
58
|
+
}).finally(() => { clearTimeout(timeout); });
|
|
59
|
+
return () => { active = false; clearTimeout(timeout); controller.abort(); };
|
|
60
|
+
}, [previewUrl, api]);
|
|
61
|
+
const source = decoded?.url === previewUrl ? decoded : null;
|
|
62
|
+
const checked = inspection?.url === previewUrl ? inspection : null;
|
|
63
|
+
const error = checked?.error;
|
|
64
|
+
return {
|
|
65
|
+
warning: shareImageWarning({ value, previewUrl, width: source?.width, previewFailed: source?.failed, byteSize: checked?.byteSize,
|
|
66
|
+
error: error ? interpolate({ template: t(error.key, error.vars), vars: error.vars ?? {} }) : undefined }, { t }),
|
|
67
|
+
onPreviewLoad: ({ currentTarget }) => {
|
|
68
|
+
const url = currentTarget.getAttribute('src');
|
|
69
|
+
if (url && url === currentUrl.current)
|
|
70
|
+
setDecoded({ url, width: currentTarget.naturalWidth });
|
|
71
|
+
},
|
|
72
|
+
onPreviewError: ({ currentTarget }) => {
|
|
73
|
+
const url = currentTarget.getAttribute('src');
|
|
74
|
+
if (url && url === currentUrl.current)
|
|
75
|
+
setDecoded({ url, failed: true });
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=use-share-image-warning.hooks.js.map
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
|
+
import { useSitemapQuery } from './SeoQuery.hooks.js';
|
|
2
3
|
import { useController } from '../../../core/react/use-controller.js';
|
|
3
4
|
import { createSitemapController } from '../../controllers/sitemap.controller.js';
|
|
4
5
|
import { parseSitemapXml, filterSitemapEntries } from '../../rules.js';
|
|
5
6
|
import { useSeoPorts } from './SeoPorts.hooks.js';
|
|
6
7
|
export function useSitemapModal({ api, enabled }, _optional = {}) {
|
|
7
|
-
const
|
|
8
|
-
const
|
|
8
|
+
const cached = useSitemapQuery({ api, enabled });
|
|
9
|
+
const { controller, snapshot } = useController({ create: () => createSitemapController({ api: cached.api, enabled }), dependencies: [cached.api, enabled] }, { start: ({ controller }) => { void controller.load({}); } });
|
|
10
|
+
const state = { ...(snapshot ?? { filter: '', view: 'table' }), status: !enabled ? 'disabled' : cached.query.error ? 'error' : cached.query.data ? 'ready' : 'loading', error: cached.query.error?.message ?? null, xmlText: cached.query.data?.text ?? '' };
|
|
9
11
|
// Parse only when text/status changes, never on each filter keystroke.
|
|
10
12
|
const entries = useMemo(() => state.status === 'ready' ? parseSitemapXml({ xmlText: state.xmlText }) : [], [state.status, state.xmlText]);
|
|
11
13
|
const filteredEntries = useMemo(() => filterSitemapEntries({ entries, query: state.filter }), [entries, state.filter]);
|
|
12
|
-
return { ...state, entries, filteredEntries, setFilter: value => controller?.setFilter({ value }), setView: view => controller?.setView({ view }), refetch:
|
|
14
|
+
return { ...state, entries, filteredEntries, setFilter: value => controller?.setFilter({ value }), setView: view => controller?.setView({ view }), refetch: cached.query.refetch };
|
|
13
15
|
}
|
|
14
16
|
export function useWiredSitemapModal({ enabled, onClose }, _optional = {}) {
|
|
15
17
|
const { seoApi: api } = useSeoPorts();
|
|
@@ -87,7 +87,7 @@ function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntr
|
|
|
87
87
|
return _jsx("div", { className: "jini-notice jini-notice-error", children: loadError });
|
|
88
88
|
if (!resolved)
|
|
89
89
|
return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entry SEO…" }) });
|
|
90
|
-
return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsxs("p", { className: "jini-muted-cell", children: [t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." }), " ", t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })] }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] }), _jsxs("label", { children: [t({ locale: locale, key: "OG title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "OG description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "OG image" }), value: fieldValue("ogImage", resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), agentHandle: "seo-entry-og-image" }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", resolved.twitter.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", resolved.twitter.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "Twitter image" }), value: fieldValue("twitterImage", resolved.twitter.image ?? "") ?? "", onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" }), _jsx("span", { className: "jini-editor-actions", children: _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
|
|
90
|
+
return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsxs("p", { className: "jini-muted-cell", children: [t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." }), " ", t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })] }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] }), _jsxs("label", { children: [t({ locale: locale, key: "OG title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "OG description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "OG image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), agentHandle: "seo-entry-og-image" }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", resolved.twitter.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", resolved.twitter.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "Twitter image" }), value: fieldValue("twitterImage", resolved.twitter.imageRef ?? resolved.twitter.image ?? "") ?? "", onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" }), _jsx("span", { className: "jini-editor-actions", children: _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
|
|
91
91
|
}
|
|
92
92
|
/** Section wrapper (REQ-06/07) — entry picker over the per-entry edit + analyze panels. This IS the
|
|
93
93
|
* "Pages & posts" tab panel; it kept its name and its markup, because nothing about it changed
|
package/dist/seo/rules.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdminSeoIssue as SeoIssue, AdminSeoSettingsPatch as SeoSettingsPatch } from "../core/ports/seo.js";
|
|
2
|
+
import type { SeoTranslator } from './models.js';
|
|
2
3
|
/**
|
|
3
4
|
* Orders issues error-first, then warning, then info, matching the severities' natural urgency.
|
|
4
5
|
* An unrecognized severity sorts last (falls back to `9`) rather than throwing, since this reads
|
|
@@ -99,7 +100,8 @@ export declare function buildMediaRef({ item }: {
|
|
|
99
100
|
};
|
|
100
101
|
}, _optional?: Record<string, never>): string;
|
|
101
102
|
/** `MediaRefField`'s thumbnail `<img src>` for the field's current value. An absolute URL (the
|
|
102
|
-
* field's other legal shape — someone pasted a raw URL) renders directly
|
|
103
|
+
* field's other legal shape — someone pasted a raw URL) renders directly unless siteOrigin
|
|
104
|
+
* identifies it as this host's rendition, in which case diagnostics inspect its original; a `{assetId}:{transform}`
|
|
103
105
|
* ref resolves through the injected `mediaOriginalUrl` builder — the admin media library's own
|
|
104
106
|
* preview URL (`MediaPickerPort.mediaOriginalUrl`), same as `MediaPickerDialog`'s own grid
|
|
105
107
|
* thumbnails use, NOT the public `/m/...` rendition URL (that needs a live workspace/transform
|
|
@@ -115,7 +117,24 @@ export declare function buildMediaRef({ item }: {
|
|
|
115
117
|
export declare function resolveMediaRefPreviewUrl({ value, mediaOriginalUrl }: {
|
|
116
118
|
value: string;
|
|
117
119
|
mediaOriginalUrl: (id: string) => string;
|
|
118
|
-
},
|
|
120
|
+
}, { siteOrigin }?: {
|
|
121
|
+
siteOrigin?: string | undefined;
|
|
122
|
+
}): string | null;
|
|
123
|
+
/** Diagnostic policy: unknown measurements stay unknown; measured failures name their reason.
|
|
124
|
+
* @param required Controlled field, decoded preview width and original HTTP metadata.
|
|
125
|
+
* @returns A non-blocking explanation, or null for an image without a measured problem.
|
|
126
|
+
* @complexity O(n) in value/error length; fixed-count checks, no I/O.
|
|
127
|
+
*/
|
|
128
|
+
export declare function shareImageWarning({ value, previewUrl, width, byteSize, error, previewFailed }: {
|
|
129
|
+
value: string;
|
|
130
|
+
previewUrl: string | null;
|
|
131
|
+
width?: number | undefined;
|
|
132
|
+
byteSize?: number | null | undefined;
|
|
133
|
+
error?: string | null | undefined;
|
|
134
|
+
previewFailed?: boolean | undefined;
|
|
135
|
+
}, { t }?: {
|
|
136
|
+
t?: SeoTranslator | undefined;
|
|
137
|
+
}): string | null;
|
|
119
138
|
/**
|
|
120
139
|
* The site-wide settings patch the defaults form submits — lifted out of the form's own inline
|
|
121
140
|
* `onSubmit` when this screen gained tabs. Same seven fields, same `checkbox === "on"` reading of
|
package/dist/seo/rules.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { interpolate } from '@jini-ai/ui/panel-kit';
|
|
1
2
|
/**
|
|
2
3
|
* @file Pure logic for the `seo` feature — everything that computes a value rather than rendering
|
|
3
4
|
* one.
|
|
@@ -138,7 +139,8 @@ export function buildMediaRef({ item }, _optional = {}) {
|
|
|
138
139
|
return `${key}:${SEO_IMAGE_TRANSFORM}`;
|
|
139
140
|
}
|
|
140
141
|
/** `MediaRefField`'s thumbnail `<img src>` for the field's current value. An absolute URL (the
|
|
141
|
-
* field's other legal shape — someone pasted a raw URL) renders directly
|
|
142
|
+
* field's other legal shape — someone pasted a raw URL) renders directly unless siteOrigin
|
|
143
|
+
* identifies it as this host's rendition, in which case diagnostics inspect its original; a `{assetId}:{transform}`
|
|
142
144
|
* ref resolves through the injected `mediaOriginalUrl` builder — the admin media library's own
|
|
143
145
|
* preview URL (`MediaPickerPort.mediaOriginalUrl`), same as `MediaPickerDialog`'s own grid
|
|
144
146
|
* thumbnails use, NOT the public `/m/...` rendition URL (that needs a live workspace/transform
|
|
@@ -151,15 +153,53 @@ export function buildMediaRef({ item }, _optional = {}) {
|
|
|
151
153
|
*
|
|
152
154
|
* @complexity O(1) — string parsing only, no I/O (the returned URL is a template; the browser
|
|
153
155
|
* performs the actual fetch only once it is used as an `<img src>`). */
|
|
154
|
-
export function resolveMediaRefPreviewUrl({ value, mediaOriginalUrl },
|
|
156
|
+
export function resolveMediaRefPreviewUrl({ value, mediaOriginalUrl }, { siteOrigin } = {}) {
|
|
155
157
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
156
158
|
if (!trimmed)
|
|
157
159
|
return null;
|
|
158
|
-
if (isAbsoluteMediaRef(trimmed))
|
|
159
|
-
|
|
160
|
+
if (isAbsoluteMediaRef(trimmed)) {
|
|
161
|
+
const key = siteOrigin ? localRenditionAssetKey({ value: trimmed, siteOrigin }) : null;
|
|
162
|
+
return key ? mediaOriginalUrl(key) : trimmed;
|
|
163
|
+
}
|
|
160
164
|
const assetId = parseMediaRefAssetId(trimmed);
|
|
161
165
|
return assetId ? mediaOriginalUrl(assetId) : null;
|
|
162
166
|
}
|
|
167
|
+
/** Only same-origin public rendition URLs identify an original in this host's media library.
|
|
168
|
+
* External URLs retain their own source. @complexity O(n) URL parsing; no I/O.
|
|
169
|
+
*/
|
|
170
|
+
function localRenditionAssetKey({ value, siteOrigin }, _optional = {}) {
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(value, siteOrigin);
|
|
173
|
+
if (url.origin !== new URL(siteOrigin).origin)
|
|
174
|
+
return null;
|
|
175
|
+
const match = url.pathname.match(/^\/m\/([^/]+)\/[^/]+\.v\d+\/[^/]+$/);
|
|
176
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Diagnostic policy: unknown measurements stay unknown; measured failures name their reason.
|
|
183
|
+
* @param required Controlled field, decoded preview width and original HTTP metadata.
|
|
184
|
+
* @returns A non-blocking explanation, or null for an image without a measured problem.
|
|
185
|
+
* @complexity O(n) in value/error length; fixed-count checks, no I/O.
|
|
186
|
+
*/
|
|
187
|
+
export function shareImageWarning({ value, previewUrl, width, byteSize, error, previewFailed }, { t = key => key } = {}) {
|
|
188
|
+
if (typeof value !== 'string' || !value.trim())
|
|
189
|
+
return t('No share image is selected. Link previews may show no picture.');
|
|
190
|
+
if (!previewUrl)
|
|
191
|
+
return t('The share image reference is invalid. Choose an image or enter an absolute image URL.');
|
|
192
|
+
if (error)
|
|
193
|
+
return error;
|
|
194
|
+
if (previewFailed)
|
|
195
|
+
return t('The share image could not be loaded. Choose an available image.');
|
|
196
|
+
const reasons = [];
|
|
197
|
+
if (width !== undefined && width < 600)
|
|
198
|
+
reasons.push(interpolate({ template: t('The share image is only {width} px wide; use an image at least 600 px wide.', { width }), vars: { width } }));
|
|
199
|
+
if (byteSize !== undefined && byteSize !== null && byteSize < 5120)
|
|
200
|
+
reasons.push(interpolate({ template: t('The share image is only {byteSize} bytes; use an image of at least 5 KB (5120 bytes).', { byteSize }), vars: { byteSize } }));
|
|
201
|
+
return reasons.length ? reasons.join(' ') : null;
|
|
202
|
+
}
|
|
163
203
|
/** One `FormData` field as a string, with the caller's fallback for an absent field. Its own named
|
|
164
204
|
* function purely so {@link buildSeoSettingsPatch} below stays readable at a glance: written
|
|
165
205
|
* inline, the seven fields' `?? `/`||` chains scored the patch builder at the ESLint ceiling
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AdminWidgetsPort } from '../../ports.js';
|
|
2
|
+
export declare const WIDGET_QUERY_KEYS: {
|
|
3
|
+
root: readonly ["widgets"];
|
|
4
|
+
library: readonly ["widgets", "library"];
|
|
5
|
+
instances: readonly ["widgets", "instance"];
|
|
6
|
+
regions: readonly ["widgets", "regions"];
|
|
7
|
+
regionDetails: readonly ["widgets", "region"];
|
|
8
|
+
};
|
|
9
|
+
/** Query ports preserve headless controller APIs. Each hook owns one fixed resource key;
|
|
10
|
+
* mutable refs keep the injected port stable without capturing stale route ids or mutations. */
|
|
11
|
+
export declare function useWidgetsListQuery({ api }: {
|
|
12
|
+
api: AdminWidgetsPort;
|
|
13
|
+
}, _optional?: Record<string, never>): {
|
|
14
|
+
api: AdminWidgetsPort;
|
|
15
|
+
query: import("@jini-ai/ui").QueryResult<{
|
|
16
|
+
widgets: import("../../models.js").AdminWidget[];
|
|
17
|
+
skippedCount?: number | undefined;
|
|
18
|
+
skippedIds?: string[] | undefined;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
export declare function useWidgetRegionsQuery({ api }: {
|
|
22
|
+
api: AdminWidgetsPort;
|
|
23
|
+
}, _optional?: Record<string, never>): {
|
|
24
|
+
api: AdminWidgetsPort;
|
|
25
|
+
query: import("@jini-ai/ui").QueryResult<{
|
|
26
|
+
regions: import("../../models.js").AdminWidgetRegionBinding[];
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
export declare function useWidgetInstanceQuery({ api, widgetId }: {
|
|
30
|
+
api: AdminWidgetsPort;
|
|
31
|
+
widgetId: string | null;
|
|
32
|
+
}, _optional?: Record<string, never>): AdminWidgetsPort;
|
|
33
|
+
export declare function useWidgetRegionQuery({ api, regionKey }: {
|
|
34
|
+
api: AdminWidgetsPort;
|
|
35
|
+
regionKey: string;
|
|
36
|
+
}, _optional?: Record<string, never>): AdminWidgetsPort;
|
|
37
|
+
//# sourceMappingURL=WidgetsQuery.hooks.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { useCachedLoader, useFetchMutation, useFetchQuery } from '@jini-ai/ui/fetch-query';
|
|
3
|
+
export const WIDGET_QUERY_KEYS = {
|
|
4
|
+
root: ['widgets'],
|
|
5
|
+
library: ['widgets', 'library'],
|
|
6
|
+
instances: ['widgets', 'instance'],
|
|
7
|
+
regions: ['widgets', 'regions'],
|
|
8
|
+
regionDetails: ['widgets', 'region'],
|
|
9
|
+
};
|
|
10
|
+
/** Query ports preserve headless controller APIs. Each hook owns one fixed resource key;
|
|
11
|
+
* mutable refs keep the injected port stable without capturing stale route ids or mutations. */
|
|
12
|
+
export function useWidgetsListQuery({ api }, _optional = {}) {
|
|
13
|
+
const fetch = useCallback(() => api.listWidgets({}), [api]);
|
|
14
|
+
const query = useFetchQuery({ key: WIDGET_QUERY_KEYS.library, fetch });
|
|
15
|
+
const loader = useCachedLoader({ key: WIDGET_QUERY_KEYS.library, fetch }, { staleTime: 0 });
|
|
16
|
+
const write = useFetchMutation({ run: ({ input }) => api.trashWidget(...input) }, { invalidates: [WIDGET_QUERY_KEYS.root] });
|
|
17
|
+
const current = useRef({ loader, write });
|
|
18
|
+
current.current = { loader, write };
|
|
19
|
+
const cachedApi = useMemo(() => ({ ...api,
|
|
20
|
+
listWidgets: () => current.current.loader.load(),
|
|
21
|
+
trashWidget: (...input) => current.current.write.mutate({ input }),
|
|
22
|
+
}), [api]);
|
|
23
|
+
return { api: cachedApi, query };
|
|
24
|
+
}
|
|
25
|
+
export function useWidgetRegionsQuery({ api }, _optional = {}) {
|
|
26
|
+
const fetch = useCallback(() => api.listWidgetRegions({}), [api]);
|
|
27
|
+
const query = useFetchQuery({ key: WIDGET_QUERY_KEYS.regions, fetch });
|
|
28
|
+
const loader = useCachedLoader({ key: WIDGET_QUERY_KEYS.regions, fetch }, { staleTime: 0 });
|
|
29
|
+
const write = useFetchMutation({ run: ({ input }) => api.bindWidgetRegion(...input) }, { invalidates: [WIDGET_QUERY_KEYS.root] });
|
|
30
|
+
const current = useRef({ loader, write });
|
|
31
|
+
current.current = { loader, write };
|
|
32
|
+
const cachedApi = useMemo(() => ({ ...api,
|
|
33
|
+
listWidgetRegions: () => current.current.loader.load(),
|
|
34
|
+
bindWidgetRegion: (...input) => current.current.write.mutate({ input }),
|
|
35
|
+
}), [api]);
|
|
36
|
+
return { api: cachedApi, query };
|
|
37
|
+
}
|
|
38
|
+
export function useWidgetInstanceQuery({ api, widgetId }, _optional = {}) {
|
|
39
|
+
const key = useMemo(() => [...WIDGET_QUERY_KEYS.instances, widgetId ?? ''], [widgetId]);
|
|
40
|
+
const fetch = useCallback(() => api.getWidget({ id: widgetId }), [api, widgetId]);
|
|
41
|
+
// The editor owns its draft: an imperative loader shares reads without replacing typed text
|
|
42
|
+
// merely because a sibling screen invalidated this widget.
|
|
43
|
+
const loader = useCachedLoader({ key, fetch }, { staleTime: 0 });
|
|
44
|
+
const create = useFetchMutation({ run: ({ input }) => api.createWidget(...input) }, { invalidates: [WIDGET_QUERY_KEYS.root] });
|
|
45
|
+
const update = useFetchMutation({ run: ({ input }) => api.updateWidget(...input) }, { invalidates: [WIDGET_QUERY_KEYS.root] });
|
|
46
|
+
const current = useRef({ loader, create, update });
|
|
47
|
+
current.current = { loader, create, update };
|
|
48
|
+
return useMemo(() => ({ ...api,
|
|
49
|
+
getWidget: () => current.current.loader.load(),
|
|
50
|
+
createWidget: (...input) => current.current.create.mutate({ input }),
|
|
51
|
+
updateWidget: (...input) => current.current.update.mutate({ input }),
|
|
52
|
+
}), [api]);
|
|
53
|
+
}
|
|
54
|
+
export function useWidgetRegionQuery({ api, regionKey }, _optional = {}) {
|
|
55
|
+
const key = useMemo(() => [...WIDGET_QUERY_KEYS.regionDetails, regionKey], [regionKey]);
|
|
56
|
+
const fetch = useCallback(() => api.getWidgetRegion({ regionKey }), [api, regionKey]);
|
|
57
|
+
const loader = useCachedLoader({ key, fetch }, { staleTime: 0 });
|
|
58
|
+
const save = useFetchMutation({ run: ({ input }) => api.mutateWidgetRegionPlacements(...input) }, { invalidates: [WIDGET_QUERY_KEYS.root] });
|
|
59
|
+
const widgetKey = useMemo(() => [...WIDGET_QUERY_KEYS.instances, 'lookup'], []);
|
|
60
|
+
const fetchWidget = useCallback(async () => { throw new Error('Widget id required'); }, []);
|
|
61
|
+
const widgetLookup = useCachedLoader({ key: widgetKey, fetch: fetchWidget });
|
|
62
|
+
const current = useRef({ loader, save, widgetLookup });
|
|
63
|
+
current.current = { loader, save, widgetLookup };
|
|
64
|
+
return useMemo(() => ({ ...api,
|
|
65
|
+
getWidgetRegion: () => current.current.loader.load(),
|
|
66
|
+
getWidget: ({ id }) => current.current.widgetLookup.load({ key: [...WIDGET_QUERY_KEYS.instances, id], fetch: () => api.getWidget({ id }) }),
|
|
67
|
+
mutateWidgetRegionPlacements: (...input) => current.current.save.mutate({ input }),
|
|
68
|
+
}), [api]);
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=WidgetsQuery.hooks.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useWidgetInstanceQuery } from './WidgetsQuery.hooks.js';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
2
3
|
import { useDirtyGuard } from '@jini-ai/ui/panel-kit';
|
|
3
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
4
5
|
import { createWidgetInstanceEditorController } from '../../controllers/instance-editor.controller.js';
|
|
@@ -8,16 +9,17 @@ import { useWidgetsPorts, useWidgetsOptions } from './WidgetsPorts.hooks.js';
|
|
|
8
9
|
import { useWidgetsNavigate } from './navigation.hooks.js';
|
|
9
10
|
export function staleVersionMessage({ t = widgetsEnglish } = {}, _optional = {}) { return t('This widget changed since you loaded it, refresh and try again.'); }
|
|
10
11
|
export function useWidgetInstanceEditor({ params, api, defaultConfig }, { t = widgetsEnglish, locale = 'en', navigate, slugRedirectPath } = {}) {
|
|
12
|
+
const cachedApi = useWidgetInstanceQuery({ api, widgetId: params.widgetId });
|
|
11
13
|
const paramsRef = useRef(params);
|
|
12
14
|
// Saves compare with the latest rendered identity, before a route effect can run.
|
|
13
15
|
const renderedKey = useRef(widgetEntityKey({ params }));
|
|
14
16
|
renderedKey.current = widgetEntityKey({ params });
|
|
15
17
|
const latest = useRef({ t, navigate, slugRedirectPath });
|
|
16
18
|
latest.current = { t, navigate, slugRedirectPath };
|
|
17
|
-
const { controller, snapshot } = useController({ create: () => createWidgetInstanceEditorController({ api, params: paramsRef.current, defaultConfig }, {
|
|
19
|
+
const { controller, snapshot } = useController({ create: () => createWidgetInstanceEditorController({ api: cachedApi, params: paramsRef.current, defaultConfig }, {
|
|
18
20
|
getEntityKey: () => renderedKey.current, t: key => latest.current.t(key), navigate: (path, options) => latest.current.navigate?.(path, options),
|
|
19
21
|
slugRedirectPath: (base, id, item) => latest.current.slugRedirectPath?.(base, id, item) ?? null,
|
|
20
|
-
}), dependencies: [
|
|
22
|
+
}), dependencies: [cachedApi, defaultConfig] }, { start: ({ controller }) => { void controller.load(); } });
|
|
21
23
|
useEffect(() => {
|
|
22
24
|
if (paramsRef.current.widgetId === params.widgetId && paramsRef.current.widgetType === params.widgetType)
|
|
23
25
|
return;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useWidgetRegionQuery } from './WidgetsQuery.hooks.js';
|
|
1
2
|
import { useEffect, useRef } from 'react';
|
|
2
3
|
import { useController } from '../../../core/react/use-controller.js';
|
|
3
4
|
import { createWidgetRegionEditorController } from '../../controllers/region-editor.controller.js';
|
|
@@ -5,9 +6,10 @@ import { widgetsEnglish } from '../../messages.en.js';
|
|
|
5
6
|
import { useWidgetsPorts, useWidgetsOptions } from './WidgetsPorts.hooks.js';
|
|
6
7
|
export function staleVersionMessage({ t = widgetsEnglish } = {}, _optional = {}) { return t('This region changed since you loaded it, refresh and try again.'); }
|
|
7
8
|
export function useWidgetRegionEditor({ regionKey, api }, { t = widgetsEnglish, locale = 'en' } = {}) {
|
|
9
|
+
const cachedApi = useWidgetRegionQuery({ api, regionKey });
|
|
8
10
|
const keyRef = useRef(regionKey), latestT = useRef(t);
|
|
9
11
|
latestT.current = t;
|
|
10
|
-
const { controller, snapshot } = useController({ create: () => createWidgetRegionEditorController({ api, regionKey: keyRef.current }, { t: key => latestT.current(key) }), dependencies: [
|
|
12
|
+
const { controller, snapshot } = useController({ create: () => createWidgetRegionEditorController({ api: cachedApi, regionKey: keyRef.current }, { t: key => latestT.current(key) }), dependencies: [cachedApi] }, { start: ({ controller }) => { void controller.load(); } });
|
|
11
13
|
useEffect(() => { if (keyRef.current !== regionKey) {
|
|
12
14
|
keyRef.current = regionKey;
|
|
13
15
|
controller?.setRegionKey({ regionKey });
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useInvalidate } from '@jini-ai/ui/fetch-query';
|
|
2
|
+
import { WIDGET_QUERY_KEYS, useWidgetRegionsQuery } from './WidgetsQuery.hooks.js';
|
|
1
3
|
import { useEffect, useRef } from 'react';
|
|
2
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
3
5
|
import { createWidgetRegionsController } from '../../controllers/regions.controller.js';
|
|
@@ -5,12 +7,14 @@ import { widgetsEnglish } from '../../messages.en.js';
|
|
|
5
7
|
import { WIDGETS_REGIONS_RESOURCE } from '../../rules.js';
|
|
6
8
|
import { useWidgetsPorts, useWidgetsOptions } from './WidgetsPorts.hooks.js';
|
|
7
9
|
export function useWidgetRegions({ api }, { t = widgetsEnglish, locale = 'en', events, navigate } = {}) {
|
|
10
|
+
const cached = useWidgetRegionsQuery({ api });
|
|
11
|
+
const invalidate = useInvalidate();
|
|
8
12
|
const latest = useRef({ t, navigate });
|
|
9
13
|
latest.current = { t, navigate };
|
|
10
|
-
const { controller, snapshot } = useController({ create: () => createWidgetRegionsController({ api }, { t: key => latest.current.t(key), navigate: path => latest.current.navigate?.(path) }), dependencies: [api] }, { start: ({ controller }) => { void controller.load(); } });
|
|
14
|
+
const { controller, snapshot } = useController({ create: () => createWidgetRegionsController({ api: cached.api }, { t: key => latest.current.t(key), navigate: path => latest.current.navigate?.(path) }), dependencies: [cached.api] }, { start: ({ controller }) => { void controller.load(); } });
|
|
11
15
|
useEffect(() => { if (!controller)
|
|
12
|
-
return; return events?.subscribe({ resource: WIDGETS_REGIONS_RESOURCE, onRefresh: () => {
|
|
13
|
-
return { ...(snapshot ?? { regions: null, error: null, newRegionKey: '', binding: false }), setNewRegionKey: value => controller?.setNewRegionKey({ value }), bind: async () => { await controller?.bind(); }, t };
|
|
16
|
+
return; return events?.subscribe({ resource: WIDGETS_REGIONS_RESOURCE, onRefresh: () => invalidate({ key: WIDGET_QUERY_KEYS.regions }) }); }, [controller, events, invalidate]);
|
|
17
|
+
return { ...(snapshot ?? { regions: null, error: null, newRegionKey: '', binding: false }), regions: cached.query.data?.regions ?? snapshot?.regions ?? null, error: snapshot?.error ?? cached.query.error?.message ?? null, setNewRegionKey: value => controller?.setNewRegionKey({ value }), bind: async () => { await controller?.bind(); }, t };
|
|
14
18
|
}
|
|
15
19
|
export function useWiredWidgetRegions() {
|
|
16
20
|
const { widgetsApi: api, widgetsEvents: events, widgetsNavigation } = useWidgetsPorts();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useInvalidate } from '@jini-ai/ui/fetch-query';
|
|
2
|
+
import { WIDGET_QUERY_KEYS, useWidgetsListQuery } from './WidgetsQuery.hooks.js';
|
|
1
3
|
import { useEffect, useRef } from 'react';
|
|
2
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
3
5
|
import { createWidgetsLibraryController } from '../../controllers/library.controller.js';
|
|
@@ -5,13 +7,15 @@ import { widgetsEnglish } from '../../messages.en.js';
|
|
|
5
7
|
import { WIDGETS_LIBRARY_RESOURCE } from '../../rules.js';
|
|
6
8
|
import { useWidgetsPorts, useWidgetsOptions } from './WidgetsPorts.hooks.js';
|
|
7
9
|
export function useWidgetsLibrary({ api }, { t = widgetsEnglish, locale = 'en', events } = {}) {
|
|
10
|
+
const cached = useWidgetsListQuery({ api });
|
|
11
|
+
const invalidate = useInvalidate();
|
|
8
12
|
const latestT = useRef(t);
|
|
9
13
|
latestT.current = t;
|
|
10
|
-
const { controller, snapshot } = useController({ create: () => createWidgetsLibraryController({ api }, { t: key => latestT.current(key) }), dependencies: [api] }, { start: ({ controller }) => { void controller.load(); } });
|
|
14
|
+
const { controller, snapshot } = useController({ create: () => createWidgetsLibraryController({ api: cached.api }, { t: key => latestT.current(key) }), dependencies: [cached.api] }, { start: ({ controller }) => { void controller.load(); } });
|
|
11
15
|
useEffect(() => { if (!controller)
|
|
12
|
-
return; return events?.subscribe({ resource: WIDGETS_LIBRARY_RESOURCE, onRefresh: () => {
|
|
16
|
+
return; return events?.subscribe({ resource: WIDGETS_LIBRARY_RESOURCE, onRefresh: () => invalidate({ key: WIDGET_QUERY_KEYS.library }) }); }, [controller, events, invalidate]);
|
|
13
17
|
const state = snapshot ?? { widgets: null, error: null, skippedCount: 0, skippedIds: [], createType: 'text', pendingTrash: null, trashingId: null };
|
|
14
|
-
return { ...state, trashing: state.pendingTrash !== null && state.trashingId === state.pendingTrash.id,
|
|
18
|
+
return { ...state, widgets: cached.query.data?.widgets ?? state.widgets, skippedCount: cached.query.data?.skippedCount ?? state.skippedCount, skippedIds: cached.query.data?.skippedIds ?? state.skippedIds, error: state.error ?? cached.query.error?.message ?? null, trashing: state.pendingTrash !== null && state.trashingId === state.pendingTrash.id,
|
|
15
19
|
setCreateType: type => controller?.setCreateType({ type }), requestTrash: widget => controller?.requestTrash({ widget }),
|
|
16
20
|
confirmTrash: async () => { await controller?.confirmTrash(); }, cancelTrash: () => controller?.cancelTrash(), t, locale };
|
|
17
21
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/admin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Composable admin surface for Jini-hosted products: framework-free contracts, panel registry, and route model in `/core`; React shell and panels in `/react`. Import only the layers you need.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -75,7 +75,8 @@
|
|
|
75
75
|
"./widgets/react": "browser",
|
|
76
76
|
"./widgets/adapters/http": "universal",
|
|
77
77
|
"./widgets/adapters/memory": "universal",
|
|
78
|
-
"./widgets/conformance": "universal"
|
|
78
|
+
"./widgets/conformance": "universal",
|
|
79
|
+
"./consent/react": "browser"
|
|
79
80
|
},
|
|
80
81
|
"isolation": {
|
|
81
82
|
"forbidden": {
|
|
@@ -406,6 +407,11 @@
|
|
|
406
407
|
"types": "./dist/widgets/conformance/widgets-api.conformance.d.ts",
|
|
407
408
|
"import": "./dist/widgets/conformance/widgets-api.conformance.js",
|
|
408
409
|
"default": "./dist/widgets/conformance/widgets-api.conformance.js"
|
|
410
|
+
},
|
|
411
|
+
"./consent/react": {
|
|
412
|
+
"types": "./dist/consent/react/index.d.ts",
|
|
413
|
+
"import": "./dist/consent/react/index.js",
|
|
414
|
+
"default": "./dist/consent/react/index.js"
|
|
409
415
|
}
|
|
410
416
|
},
|
|
411
417
|
"files": [
|
|
@@ -427,12 +433,12 @@
|
|
|
427
433
|
"peerDependencies": {
|
|
428
434
|
"react": "^18.3.0 || ^19.0.0",
|
|
429
435
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
430
|
-
"@jini-ai/agentic": "^0.4.
|
|
431
|
-
"@jini-ai/ui": "^0.4.
|
|
432
|
-
"@jini-ai/ui-kit": "^0.1.
|
|
433
|
-
"@jini-ai/agent-plugins": "^0.4.
|
|
434
|
-
"@jini-ai/cms": "^0.5.
|
|
435
|
-
"@jini-ai/core": "^0.4.
|
|
436
|
+
"@jini-ai/agentic": "^0.4.4",
|
|
437
|
+
"@jini-ai/ui": "^0.4.8",
|
|
438
|
+
"@jini-ai/ui-kit": "^0.1.3",
|
|
439
|
+
"@jini-ai/agent-plugins": "^0.4.3",
|
|
440
|
+
"@jini-ai/cms": "^0.5.6",
|
|
441
|
+
"@jini-ai/core": "^0.4.3"
|
|
436
442
|
},
|
|
437
443
|
"peerDependenciesMeta": {
|
|
438
444
|
"react": {
|
|
@@ -470,12 +476,12 @@
|
|
|
470
476
|
"react": "^19.2.0",
|
|
471
477
|
"react-dom": "^19.2.0",
|
|
472
478
|
"@testing-library/user-event": "^14.6.1",
|
|
473
|
-
"@jini-ai/
|
|
474
|
-
"@jini-ai/cms": "^0.5.
|
|
475
|
-
"@jini-ai/
|
|
476
|
-
"@jini-ai/
|
|
477
|
-
"@jini-ai/ui": "^0.4.
|
|
478
|
-
"@jini-ai/
|
|
479
|
+
"@jini-ai/agentic": "^0.4.4",
|
|
480
|
+
"@jini-ai/cms": "^0.5.6",
|
|
481
|
+
"@jini-ai/agent-plugins": "^0.4.3",
|
|
482
|
+
"@jini-ai/core": "^0.4.3",
|
|
483
|
+
"@jini-ai/ui": "^0.4.8",
|
|
484
|
+
"@jini-ai/ui-kit": "^0.1.3"
|
|
479
485
|
},
|
|
480
486
|
"typesVersions": {
|
|
481
487
|
"*": {
|