@jini-ai/admin 0.4.10 → 0.4.12
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/CHANGELOG.md +10 -0
- package/dist/comments/rules.d.ts +3 -3
- package/dist/comments/rules.js +3 -3
- package/dist/core/ports/seo.d.ts +20 -0
- package/dist/forms/field-settings.d.ts +1 -0
- package/dist/forms/field-settings.js +4 -4
- package/dist/forms/react/components/FieldAttributesDialog.js +1 -1
- package/dist/forms/react/hooks/use-forms-list.hooks.d.ts +1 -1
- package/dist/forms/rules.d.ts +2 -2
- package/dist/forms/rules.js +2 -2
- package/dist/redirects/react/hooks/RedirectsPage.hooks.js +1 -1
- package/dist/redirects/react/pages/RedirectsPage.js +2 -1
- package/dist/redirects/rules.d.ts +2 -2
- package/dist/redirects/rules.js +2 -2
- package/dist/seo/adapters/http.js +5 -0
- package/dist/seo/adapters/memory.js +1 -1
- package/dist/seo/messages.en.d.ts +15 -10
- package/dist/seo/messages.en.js +15 -10
- package/dist/seo/models.d.ts +11 -0
- package/dist/seo/react/components/MediaRefField.d.ts +17 -5
- package/dist/seo/react/components/MediaRefField.js +14 -4
- package/dist/seo/react/components/SitemapModal.js +1 -1
- package/dist/seo/react/hooks/MediaRefField.hooks.js +10 -2
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
- package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
- package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
- package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
- package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -0
- package/dist/seo/react/index.d.ts +45 -30
- package/dist/seo/react/pages/SeoPage.d.ts +3 -1
- package/dist/seo/react/pages/SeoPage.js +26 -24
- package/dist/seo/rules.d.ts +95 -4
- package/dist/seo/rules.js +134 -8
- package/dist/seo/seo.module.d.ts +15 -10
- package/package.json +14 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.12 — 2026-10-16
|
|
4
|
+
|
|
5
|
+
- SEO: searchable entry picker and grouped per-entry form (6c128f9d); image fields name the file, styled analysis, slug for same-titled entries (bc1dbf0d); one Social preview section with an optional "Customize for X" (ff1a709f); X/Twitter labels, file names with extensions, muted inherited site-default image (4919371d); "Using image from page content" when the share image comes from the page (746457d9).
|
|
6
|
+
- Peers: `@jini-ai/ui` `^0.5.5` (grouped Select) and `@jini-ai/cms` `^0.5.13` (share-image source).
|
|
7
|
+
|
|
8
|
+
## 0.4.11 — 2026-10-14
|
|
9
|
+
|
|
10
|
+
- `FieldAttributesDialog` and `SitemapModal` use the neutral `jini-domain-dialog` class instead of a host-named one; hosts style `.jini-domain-dialog` (47b9fa4f).
|
|
11
|
+
- Rides along, no API change: forms option row keys come from the editor's own row list instead of a module-level counter (6281ddc8); redirects imports its shared widgets directly instead of the React barrel (9b45c742); comment scrubs (8e508fb6, 6281ddc8).
|
|
12
|
+
|
|
3
13
|
## 0.4.10 — 2026-10-13
|
|
4
14
|
|
|
5
15
|
- Forms HTML starter: URL fields emit `type="text"` plus the registry's URL hints (`inputmode`, `autocomplete`, `spellcheck`, `autocapitalize`), so bare domains such as `tovu.dev` are no longer refused by the browser. CMS peer floor ^0.5.11.
|
package/dist/comments/rules.d.ts
CHANGED
|
@@ -31,11 +31,11 @@ export declare const KEYS: {
|
|
|
31
31
|
/** The moderation queue's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s
|
|
32
32
|
* `TAXONOMY_RESOURCE` for why this is a plain colocated constant rather than a shared registry.
|
|
33
33
|
* Agent-writable via `comments_approve_comment`/`comments_mark_comment_spam`/
|
|
34
|
-
* `comments_trash_comment`/`comments_restore_comment` (
|
|
35
|
-
*
|
|
34
|
+
* `comments_trash_comment`/`comments_restore_comment` (the host's comment agent tools),
|
|
35
|
+
* all of which move a comment between statuses this queue lists.
|
|
36
36
|
*
|
|
37
37
|
* There is deliberately NO sibling `COMMENTS_SETTINGS_RESOURCE`: `react/hooks/use-comment-settings.hooks.ts`'s
|
|
38
|
-
* own header records a real, already-fixed lost-update bug (
|
|
38
|
+
* own header records a real, already-fixed lost-update bug (2026-08-12) whose fix is a
|
|
39
39
|
* ONE-SHOT seed guard that refuses to re-seed `settings` from any later successful load, including
|
|
40
40
|
* this bus's own out-of-band notification. Wiring that hook to the bus would either do nothing
|
|
41
41
|
* (the guard blocks it) or, if the guard were bypassed instead, reintroduce the exact silent
|
package/dist/comments/rules.js
CHANGED
|
@@ -35,11 +35,11 @@ export const KEYS = {
|
|
|
35
35
|
/** The moderation queue's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s
|
|
36
36
|
* `TAXONOMY_RESOURCE` for why this is a plain colocated constant rather than a shared registry.
|
|
37
37
|
* Agent-writable via `comments_approve_comment`/`comments_mark_comment_spam`/
|
|
38
|
-
* `comments_trash_comment`/`comments_restore_comment` (
|
|
39
|
-
*
|
|
38
|
+
* `comments_trash_comment`/`comments_restore_comment` (the host's comment agent tools),
|
|
39
|
+
* all of which move a comment between statuses this queue lists.
|
|
40
40
|
*
|
|
41
41
|
* There is deliberately NO sibling `COMMENTS_SETTINGS_RESOURCE`: `react/hooks/use-comment-settings.hooks.ts`'s
|
|
42
|
-
* own header records a real, already-fixed lost-update bug (
|
|
42
|
+
* own header records a real, already-fixed lost-update bug (2026-08-12) whose fix is a
|
|
43
43
|
* ONE-SHOT seed guard that refuses to re-seed `settings` from any later successful load, including
|
|
44
44
|
* this bus's own out-of-band notification. Wiring that hook to the bus would either do nothing
|
|
45
45
|
* (the guard blocks it) or, if the guard were bypassed instead, reintroduce the exact silent
|
package/dist/core/ports/seo.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ export interface AdminSeoOpenGraph {
|
|
|
76
76
|
readonly image?: string;
|
|
77
77
|
/** Source reference before resizing, used to diagnose the selected share image. */
|
|
78
78
|
readonly imageRef?: string;
|
|
79
|
+
/** Effective image provenance; absent from older hosts. */
|
|
80
|
+
readonly imageSource?: 'override' | 'content' | 'default';
|
|
81
|
+
/** Present (true) only when the entry stores its own image override; absent from older hosts. */
|
|
82
|
+
readonly imageOverridden?: true;
|
|
79
83
|
readonly siteName?: string;
|
|
80
84
|
}
|
|
81
85
|
export interface AdminSeoTwitterCard {
|
|
@@ -149,6 +153,18 @@ export interface AdminSeoEntryChoice {
|
|
|
149
153
|
readonly id: string;
|
|
150
154
|
readonly title: string;
|
|
151
155
|
readonly status: string;
|
|
156
|
+
/** Tells two same-titled entries apart in the picker; optional for hosts that omit it. */
|
|
157
|
+
readonly slug?: string;
|
|
158
|
+
}
|
|
159
|
+
/** What an image field shows for a chosen library file: its name and, when stored, its size. */
|
|
160
|
+
export interface AdminSeoMediaSummary {
|
|
161
|
+
readonly id: string;
|
|
162
|
+
readonly slug?: string;
|
|
163
|
+
readonly title: string;
|
|
164
|
+
readonly width?: number | null;
|
|
165
|
+
readonly height?: number | null;
|
|
166
|
+
/** The stored bytes' sniffed type (`image/png`); gives the displayed name its extension. */
|
|
167
|
+
readonly contentType?: string | null;
|
|
152
168
|
}
|
|
153
169
|
export interface AdminSeoPort {
|
|
154
170
|
getSeoEntry(requiredArgs: {
|
|
@@ -189,6 +205,10 @@ export interface AdminSeoPort {
|
|
|
189
205
|
mediaPreviewUrl?(required: {
|
|
190
206
|
value: string;
|
|
191
207
|
}, optional?: Record<string, never>): string | null;
|
|
208
|
+
/** Optional: library rows so an image field can name its file instead of showing a reference. */
|
|
209
|
+
listSeoMedia?(required: Record<string, never>, optional?: {
|
|
210
|
+
signal?: AbortSignal;
|
|
211
|
+
}): Promise<readonly AdminSeoMediaSummary[]>;
|
|
192
212
|
/** Original thumbnail URL; media-reference resolution stays on the server. */
|
|
193
213
|
mediaOriginalUrl(requiredArgs: {
|
|
194
214
|
id: string;
|
|
@@ -65,6 +65,7 @@ export declare function retypeField({ field, type }: {
|
|
|
65
65
|
field: AdminFormField;
|
|
66
66
|
type: AdminFormField["type"];
|
|
67
67
|
}, _optional?: Record<string, never>): AdminFormField;
|
|
68
|
+
/** Local React keys for option rows; identity is scoped to the editor's own row list. */
|
|
68
69
|
export interface OptionRow {
|
|
69
70
|
_rowId: number;
|
|
70
71
|
value: string;
|
|
@@ -134,8 +134,6 @@ export function retypeField({ field, type }, _optional = {}) {
|
|
|
134
134
|
next.default = field.default;
|
|
135
135
|
return next;
|
|
136
136
|
}
|
|
137
|
-
/** Local React keys for option rows; same module-counter pattern as `rules.ts`'s attribute rows. */
|
|
138
|
-
let nextOptionRowId = 0;
|
|
139
137
|
const text = (value) => (value === undefined || value === null ? "" : String(value));
|
|
140
138
|
/** Seed the draft from a stored field. @complexity O(o) in options. */
|
|
141
139
|
export function settingsFormFromField({ field }, _optional = {}) {
|
|
@@ -147,7 +145,7 @@ export function settingsFormFromField({ field }, _optional = {}) {
|
|
|
147
145
|
defaultChoices: Array.isArray(defaults) ? [...defaults] : typeof defaults === "string" && fieldSettingsFor({ type: field.type }).options ? [defaults] : [],
|
|
148
146
|
minLength: text(field.minLength), maxLength: text(field.maxLength), pattern: text(field.pattern),
|
|
149
147
|
min: text(field.min), max: text(field.max), step: text(field.step),
|
|
150
|
-
options: (field.options ?? []).map((option) => ({ _rowId:
|
|
148
|
+
options: (field.options ?? []).map((option, index) => ({ _rowId: index, value: option.value, label: option.label })),
|
|
151
149
|
accept: [...(field.accept ?? [])],
|
|
152
150
|
maxFileSizeMb: field.maxFileSize === undefined ? "" : String(Math.round((field.maxFileSize / 1024 / 1024) * 100) / 100),
|
|
153
151
|
};
|
|
@@ -167,7 +165,9 @@ export function updateOptionRow({ rows, rowId, patch }, _optional = {}) {
|
|
|
167
165
|
}
|
|
168
166
|
/** @complexity O(o). */
|
|
169
167
|
export function addOptionRow({ rows }, _optional = {}) {
|
|
170
|
-
|
|
168
|
+
// Use the highest current key so deleting or reordering rows cannot create a duplicate.
|
|
169
|
+
const rowId = rows.reduce((max, row) => Math.max(max, row._rowId), -1) + 1;
|
|
170
|
+
return [...rows, { _rowId: rowId, value: "", label: "" }];
|
|
171
171
|
}
|
|
172
172
|
/** @complexity O(o). */
|
|
173
173
|
export function removeOptionRow({ rows, rowId }, _optional = {}) {
|
|
@@ -19,7 +19,7 @@ export function FieldAttributesDialog({ field, fieldIndex, onSave, onCancel, use
|
|
|
19
19
|
// it was not judged worth the added indirection.
|
|
20
20
|
const { className, addRow, error, submit } = controller;
|
|
21
21
|
const { rows, displayName, onClose, onClassName, hasStyling } = useFieldAttributesView({ field, fieldIndex, controller, onCancel });
|
|
22
|
-
return (_jsx(Dialog, { open: true, title: `${t("Field settings —")} ${displayName}`, onClose: onClose, className: "settings-dialog
|
|
22
|
+
return (_jsx(Dialog, { open: true, title: `${t("Field settings —")} ${displayName}`, onClose: onClose, className: "settings-dialog jini-domain-dialog field-attrs-dialog", children: _jsxs("form", { onSubmit: submit, ...agentHandle({ handle: "form-field-attrs-dialog" }, {
|
|
23
23
|
role: "region",
|
|
24
24
|
label: "Field settings dialog — type-specific settings, CSS classes and HTML attributes for one form field",
|
|
25
25
|
}), children: [_jsx(FieldSettingsSection, { field: field, controller: controller, t: t }), _jsxs("details", { className: "field-settings-advanced", open: hasStyling, children: [_jsx("summary", { children: t("CSS classes and HTML attributes") }), _jsxs("div", { className: "field", children: [_jsxs("span", { className: "field-settings-label", children: [_jsx("label", { className: "field-label", htmlFor: "field-attrs-classname", children: t("CSS classes") }), _jsx(InfoTip, { label: t("Classes are unrestricted, e.g. Tailwind md:col-span-2. Attribute names must be aria-*, data-* or a safe layout attribute; event handlers like onclick are rejected.") })] }), _jsx("input", { id: "field-attrs-classname", value: className, placeholder: "e.g. md:col-span-2 w-1/2 focus:ring-2", onChange: onClassName, ...agentHandle({ handle: "form-field-attrs-classname" }, {
|
|
@@ -37,7 +37,7 @@ import { type FormDateLine } from "../../rules.js";
|
|
|
37
37
|
*
|
|
38
38
|
* the scoped formsEvents subscription (staleness-bug generalization pass, see that hook's own header):
|
|
39
39
|
* `forms_create_definition`/`forms_update_definition`/`forms_set_definition_status`
|
|
40
|
-
* (
|
|
40
|
+
* (the host's forms agent tools) are agent-callable, so this list re-invalidates
|
|
41
41
|
* `KEYS.list` on an out-of-band content-refresh notification the same way `use-taxonomy.hooks.ts`
|
|
42
42
|
* does for its own resource.
|
|
43
43
|
*/
|
package/dist/forms/rules.d.ts
CHANGED
|
@@ -31,8 +31,8 @@ import { type FieldType } from "@jini-ai/cms/forms";
|
|
|
31
31
|
/**
|
|
32
32
|
* This screen's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
|
|
33
33
|
* for why this is a plain colocated constant rather than a shared registry. `forms_create_definition`/
|
|
34
|
-
* `forms_update_definition`/`forms_set_definition_status` (
|
|
35
|
-
*
|
|
34
|
+
* `forms_update_definition`/`forms_set_definition_status` (the host's forms agent tools)
|
|
35
|
+
* are agent-callable, so `FormsList.tsx` needs the same "an assistant write shows up
|
|
36
36
|
* without a reload" fix `use-taxonomy.hooks.ts` shipped first.
|
|
37
37
|
*/
|
|
38
38
|
export declare const FORMS_LIST_RESOURCE = "forms";
|
package/dist/forms/rules.js
CHANGED
|
@@ -30,8 +30,8 @@ import { FIELD_TYPE_REGISTRY } from "@jini-ai/cms/forms";
|
|
|
30
30
|
/**
|
|
31
31
|
* This screen's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
|
|
32
32
|
* for why this is a plain colocated constant rather than a shared registry. `forms_create_definition`/
|
|
33
|
-
* `forms_update_definition`/`forms_set_definition_status` (
|
|
34
|
-
*
|
|
33
|
+
* `forms_update_definition`/`forms_set_definition_status` (the host's forms agent tools)
|
|
34
|
+
* are agent-callable, so `FormsList.tsx` needs the same "an assistant write shows up
|
|
35
35
|
* without a reload" fix `use-taxonomy.hooks.ts` shipped first.
|
|
36
36
|
*/
|
|
37
37
|
export const FORMS_LIST_RESOURCE = "forms";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
|
-
import { RowMenu } from '../../../react/
|
|
2
|
+
import { RowMenu } from '../../../react/components/RowMenu/RowMenu.js';
|
|
3
3
|
import { redirectRowMenuItems } from '../../rules.js';
|
|
4
4
|
import { HitCountCell } from '../components/HitCountCell.js';
|
|
5
5
|
import { useRedirectsOptions } from './RedirectsOptions.hooks.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { DataTable
|
|
2
|
+
import { DataTable } from '../../../react/components/DataTable.js';
|
|
3
|
+
import { ConfirmDialog } from '../../../react/components/ConfirmDialog/ConfirmDialog.js';
|
|
3
4
|
import { agentHandle } from '@jini-ai/agentic';
|
|
4
5
|
import { describeApiError } from '../../../core/transport/errors.js';
|
|
5
6
|
import { ImportRedirectsForm } from '../components/ImportRedirectsForm.js';
|
|
@@ -22,8 +22,8 @@ export declare const KEYS: {
|
|
|
22
22
|
};
|
|
23
23
|
/** This screen's name on the host refresh bus — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
|
|
24
24
|
* for why this is a plain colocated constant rather than a shared registry. Agent-writable via
|
|
25
|
-
* `redirects_create`/`redirects_update`/`redirects_tombstone` (
|
|
26
|
-
*
|
|
25
|
+
* `redirects_create`/`redirects_update`/`redirects_tombstone` (the host's redirect agent tools) —
|
|
26
|
+
* `redirects_import` is excluded from that catalog entirely, but the three wired
|
|
27
27
|
* single-row writes are enough to make this list go stale the same way Posts/Media did. */
|
|
28
28
|
export declare const REDIRECTS_RESOURCE = "redirects";
|
|
29
29
|
/**
|
package/dist/redirects/rules.js
CHANGED
|
@@ -18,8 +18,8 @@ export const KEYS = {
|
|
|
18
18
|
};
|
|
19
19
|
/** This screen's name on the host refresh bus — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
|
|
20
20
|
* for why this is a plain colocated constant rather than a shared registry. Agent-writable via
|
|
21
|
-
* `redirects_create`/`redirects_update`/`redirects_tombstone` (
|
|
22
|
-
*
|
|
21
|
+
* `redirects_create`/`redirects_update`/`redirects_tombstone` (the host's redirect agent tools) —
|
|
22
|
+
* `redirects_import` is excluded from that catalog entirely, but the three wired
|
|
23
23
|
* single-row writes are enough to make this list go stale the same way Posts/Media did. */
|
|
24
24
|
export const REDIRECTS_RESOURCE = "redirects";
|
|
25
25
|
/**
|
|
@@ -20,6 +20,11 @@ export function createHttpSeoApi({ transport, basePath }, { sitemapTransport, im
|
|
|
20
20
|
analyzeSeoEntry: ({ entryId }) => data(`${entryPath(entryId)}/analyze`, 'GET'),
|
|
21
21
|
listSeoPosts: () => choices('posts'),
|
|
22
22
|
listSeoPages: () => choices('pages'),
|
|
23
|
+
// The admin media list route the picker already reads; projected so no media row type leaks in.
|
|
24
|
+
async listSeoMedia() {
|
|
25
|
+
const response = await transport.request({ path: `${base}/media`, method: 'GET' });
|
|
26
|
+
return response.media.map(({ id, slug, title, width, height, contentType }) => ({ id, title, ...(slug ? { slug } : {}), width: width ?? null, height: height ?? null, contentType: contentType ?? null }));
|
|
27
|
+
},
|
|
23
28
|
mediaOriginalUrl: ({ id }) => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }),
|
|
24
29
|
mediaPreviewUrl: ({ value }) => resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }) }, { siteOrigin }),
|
|
25
30
|
...(imageTransport ? { async inspectShareImage({ url }, options = {}) {
|
|
@@ -49,7 +49,7 @@ function applySeoPatch(meta, patch) {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
/** One of the three site-wide scalars the server registers as `nullable: true` (`SEO_DEFINITIONS`,
|
|
52
|
-
* `
|
|
52
|
+
* `features/seo/settings.ts`), under the real write's own rule: an omitted key
|
|
53
53
|
* (`undefined`) keeps the current value, `null` clears it, and a string overwrites. */
|
|
54
54
|
function settingOrCleared(patchValue, currentValue) {
|
|
55
55
|
if (patchValue === undefined)
|
|
@@ -20,21 +20,24 @@ export declare const seoMessagesEn: {
|
|
|
20
20
|
readonly "Canonical URL": "Canonical URL";
|
|
21
21
|
readonly Noindex: "Noindex";
|
|
22
22
|
readonly Nofollow: "Nofollow";
|
|
23
|
-
readonly "
|
|
24
|
-
readonly "
|
|
25
|
-
readonly "
|
|
26
|
-
readonly "Twitter title": "Twitter title";
|
|
27
|
-
readonly "Twitter description": "Twitter description";
|
|
28
|
-
readonly "Twitter image": "Twitter image";
|
|
23
|
+
readonly "Preview title": "Preview title";
|
|
24
|
+
readonly "Preview description": "Preview description";
|
|
25
|
+
readonly "Preview image": "Preview image";
|
|
26
|
+
readonly "X/Twitter title": "X/Twitter title";
|
|
27
|
+
readonly "X/Twitter description": "X/Twitter description";
|
|
28
|
+
readonly "X/Twitter image": "X/Twitter image";
|
|
29
|
+
readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
|
|
30
|
+
readonly "Same as the preview image": "Same as the preview image";
|
|
31
|
+
readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
|
|
29
32
|
readonly "Saving\u2026": "Saving…";
|
|
30
33
|
readonly "Save overrides": "Save overrides";
|
|
31
34
|
readonly "Per-entry SEO": "Per-entry SEO";
|
|
32
35
|
readonly "Search appearance": "Search appearance";
|
|
33
36
|
readonly "Title template (must contain %s)": "Title template (must contain %s)";
|
|
34
37
|
readonly "Default meta description": "Default meta description";
|
|
35
|
-
readonly "Social
|
|
36
|
-
readonly "Default
|
|
37
|
-
readonly "Twitter @
|
|
38
|
+
readonly "Social preview": "Social preview";
|
|
39
|
+
readonly "Default preview image": "Default preview image";
|
|
40
|
+
readonly "X/Twitter @handle": "X/Twitter @handle";
|
|
38
41
|
readonly Crawling: "Crawling";
|
|
39
42
|
readonly "Default noindex": "Default noindex";
|
|
40
43
|
readonly "Default nofollow": "Default nofollow";
|
|
@@ -47,7 +50,7 @@ export declare const seoMessagesEn: {
|
|
|
47
50
|
readonly "View sitemap": "View sitemap";
|
|
48
51
|
readonly "Loading SEO settings\u2026": "Loading SEO settings…";
|
|
49
52
|
readonly Marketing: "Marketing";
|
|
50
|
-
readonly "Site-wide defaults for meta titles, descriptions,
|
|
53
|
+
readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
|
|
51
54
|
readonly "Choose image": "Choose image";
|
|
52
55
|
readonly Remove: "Remove";
|
|
53
56
|
readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
|
|
@@ -75,5 +78,7 @@ export declare const seoMessagesEn: {
|
|
|
75
78
|
readonly "failed to load the sitemap": "failed to load the sitemap";
|
|
76
79
|
readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
|
|
77
80
|
readonly "Selected image": "Selected image";
|
|
81
|
+
readonly "Using site default: {name}": "Using site default: {name}";
|
|
82
|
+
readonly "Using image from page content: {name}": "Using image from page content: {name}";
|
|
78
83
|
};
|
|
79
84
|
//# sourceMappingURL=messages.en.d.ts.map
|
package/dist/seo/messages.en.js
CHANGED
|
@@ -20,21 +20,24 @@ export const seoMessagesEn = {
|
|
|
20
20
|
"Canonical URL": "Canonical URL",
|
|
21
21
|
"Noindex": "Noindex",
|
|
22
22
|
"Nofollow": "Nofollow",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"Twitter title": "Twitter title",
|
|
27
|
-
"Twitter description": "Twitter description",
|
|
28
|
-
"Twitter image": "Twitter image",
|
|
23
|
+
"Preview title": "Preview title",
|
|
24
|
+
"Preview description": "Preview description",
|
|
25
|
+
"Preview image": "Preview image",
|
|
26
|
+
"X/Twitter title": "X/Twitter title",
|
|
27
|
+
"X/Twitter description": "X/Twitter description",
|
|
28
|
+
"X/Twitter image": "X/Twitter image",
|
|
29
|
+
"Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)",
|
|
30
|
+
"Same as the preview image": "Same as the preview image",
|
|
31
|
+
"Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter",
|
|
29
32
|
"Saving…": "Saving…",
|
|
30
33
|
"Save overrides": "Save overrides",
|
|
31
34
|
"Per-entry SEO": "Per-entry SEO",
|
|
32
35
|
"Search appearance": "Search appearance",
|
|
33
36
|
"Title template (must contain %s)": "Title template (must contain %s)",
|
|
34
37
|
"Default meta description": "Default meta description",
|
|
35
|
-
"Social
|
|
36
|
-
"Default
|
|
37
|
-
"Twitter @
|
|
38
|
+
"Social preview": "Social preview",
|
|
39
|
+
"Default preview image": "Default preview image",
|
|
40
|
+
"X/Twitter @handle": "X/Twitter @handle",
|
|
38
41
|
"Crawling": "Crawling",
|
|
39
42
|
"Default noindex": "Default noindex",
|
|
40
43
|
"Default nofollow": "Default nofollow",
|
|
@@ -47,7 +50,7 @@ export const seoMessagesEn = {
|
|
|
47
50
|
"View sitemap": "View sitemap",
|
|
48
51
|
"Loading SEO settings…": "Loading SEO settings…",
|
|
49
52
|
"Marketing": "Marketing",
|
|
50
|
-
"Site-wide defaults for meta titles, descriptions,
|
|
53
|
+
"Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.",
|
|
51
54
|
"Choose image": "Choose image",
|
|
52
55
|
"Remove": "Remove",
|
|
53
56
|
"Sitemap · {count} URLs": "Sitemap · {count} URLs",
|
|
@@ -75,5 +78,7 @@ export const seoMessagesEn = {
|
|
|
75
78
|
"failed to load the sitemap": "failed to load the sitemap",
|
|
76
79
|
"Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).",
|
|
77
80
|
"Selected image": "Selected image",
|
|
81
|
+
"Using site default: {name}": "Using site default: {name}",
|
|
82
|
+
"Using image from page content: {name}": "Using image from page content: {name}",
|
|
78
83
|
};
|
|
79
84
|
//# sourceMappingURL=messages.en.js.map
|
package/dist/seo/models.d.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface SeoController {
|
|
|
75
75
|
}
|
|
76
76
|
export interface EntryPickerController {
|
|
77
77
|
entries: readonly AdminPost[] | null;
|
|
78
|
+
/** Searchable-picker rows (grouped Pages/Posts); null while entries load. */
|
|
79
|
+
options?: import('./rules.js').SeoEntryPickerOption[] | null;
|
|
78
80
|
error: string | null;
|
|
79
81
|
}
|
|
80
82
|
export interface SeoEntryPanelOptions {
|
|
@@ -98,6 +100,9 @@ export interface SeoEntryPanelController {
|
|
|
98
100
|
* `null` for each one they emptied. An untouched field is ABSENT, never `null`: "unchanged" must
|
|
99
101
|
* never become "clear". */
|
|
100
102
|
touched: SeoEntryOverridesPatch;
|
|
103
|
+
/** The social preview image comes from content or the site default (`ogImageInherited`, rules.ts). Optional
|
|
104
|
+
* for injected legacy views. */
|
|
105
|
+
ogImageInherited?: false | 'default' | 'content';
|
|
101
106
|
}
|
|
102
107
|
export interface SeoEntrySectionController {
|
|
103
108
|
entryId: string;
|
|
@@ -153,6 +158,12 @@ export interface MediaRefFieldController {
|
|
|
153
158
|
/** The field's current value resolved to a thumbnail `<img src>`, or `null` when the value is
|
|
154
159
|
* empty or unparseable — see `resolveMediaRefPreviewUrl`'s own doc. */
|
|
155
160
|
previewUrl: string | null;
|
|
161
|
+
/** The chosen file's display name and pixel size (`describeMediaRef`), or null when empty.
|
|
162
|
+
* Optional for injected legacy views. */
|
|
163
|
+
file?: {
|
|
164
|
+
name: string;
|
|
165
|
+
size: string | null;
|
|
166
|
+
} | null;
|
|
156
167
|
/** Shared diagnostics for defaults, OG and Twitter fields; optional for injected legacy views. */
|
|
157
168
|
warning?: string | null;
|
|
158
169
|
onPreviewLoad?: (event: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useWiredMediaRefField } from "../hooks/MediaRefField.hooks.js";
|
|
2
2
|
/**
|
|
3
|
-
* @file `MediaRefField` —
|
|
4
|
-
*
|
|
3
|
+
* @file `MediaRefField` — an `{assetId}:{transformName}` media reference (or an absolute URL),
|
|
4
|
+
* shown as the chosen file's thumbnail and name, plus a "Choose image" affordance that opens the existing
|
|
5
5
|
* `MediaPickerDialog`, a thumbnail preview of the current value, and a Remove control.
|
|
6
6
|
*
|
|
7
7
|
* Built for `react/pages/SeoPage.tsx`'s `defaultOgImage`/`ogImage`/`twitterImage` fields (SPEC intent: "make the OG
|
|
@@ -16,11 +16,15 @@ import { useWiredMediaRefField } from "../hooks/MediaRefField.hooks.js";
|
|
|
16
16
|
* 2026-10-08 polish: the original editable-input rationale above still applies to pasted URLs
|
|
17
17
|
* and empty fields. A selected library reference now shows plain copy with its preview; Remove
|
|
18
18
|
* returns to manual entry. A hidden input preserves the exact FormData value on defaults saves.
|
|
19
|
+
* 2026-10-15: superseded — the input is removed entirely (owner: the read-only box "looks
|
|
20
|
+
* useless"). An existing pasted URL still displays (by its file name) and still saves unchanged;
|
|
21
|
+
* a new URL can no longer be typed here, only chosen from the library.
|
|
19
22
|
*/
|
|
20
23
|
export interface MediaRefFieldProps {
|
|
21
24
|
locale: string;
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
25
|
+
/** Base id: the caption is `{id}-label` and names the row's `role="group"`; the hidden input (when
|
|
26
|
+
* `name` is set) carries the id itself. Was an `<input id>`/`<label htmlFor>` pair until the text
|
|
27
|
+
* box was removed (2026-10-15). */
|
|
24
28
|
id: string;
|
|
25
29
|
/** Forwarded to the `<input name>` only when set — `defaultOgImage` needs this (its `<form>` reads
|
|
26
30
|
* `FormData` by name on submit); the per-entry `ogImage`/`twitterImage` fields do not (they save
|
|
@@ -32,9 +36,17 @@ export interface MediaRefFieldProps {
|
|
|
32
36
|
/** This field's own base handle — appended `-choose`/`-clear`/`-dialog` for its own controls, and
|
|
33
37
|
* forwarded as-is to the `<input>` itself. Omit to leave every element here untagged. */
|
|
34
38
|
agentHandle?: string;
|
|
39
|
+
/** Shown in the file slot while no image is set — for a field that inherits another field's
|
|
40
|
+
* image when empty (the X image falls back to the social preview image). */
|
|
41
|
+
emptyHint?: string;
|
|
42
|
+
/** The value comes from the site default or page content, with no SEO override of its own: the file
|
|
43
|
+
* shows muted as "Using site default: …" or "Using image from page content: …"; size and Remove
|
|
44
|
+
* are hidden — there is no SEO override to remove. "Choose image" is how an override starts
|
|
45
|
+
* (owner, 2026-10-15). */
|
|
46
|
+
inherited?: false | 'default' | 'content';
|
|
35
47
|
/** Injectable seam for the picker's own state — same convention as `MediaPickerDialog`'s own
|
|
36
48
|
* `useDialog` prop. Defaults to the real {@link useWiredMediaRefField}. */
|
|
37
49
|
useField?: typeof useWiredMediaRefField;
|
|
38
50
|
}
|
|
39
|
-
export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField, }: MediaRefFieldProps, _optional?: Record<string, never>): import("react").JSX.Element;
|
|
51
|
+
export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited, useField, }: MediaRefFieldProps, _optional?: Record<string, never>): import("react").JSX.Element;
|
|
40
52
|
//# sourceMappingURL=MediaRefField.d.ts.map
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { agentHandle } from "@jini-ai/agentic";
|
|
3
|
+
import { interpolate } from "@jini-ai/ui/i18n";
|
|
3
4
|
import { MediaPickerBridge } from "./MediaPickerBridge.js";
|
|
4
5
|
import { useSeoCopy } from "../hooks/SeoPorts.hooks.js";
|
|
5
6
|
import { useWiredMediaRefField, useMediaRefPresentation, handleSpread } from "../hooks/MediaRefField.hooks.js";
|
|
6
|
-
export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField = useWiredMediaRefField, }, _optional = {}) {
|
|
7
|
+
export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited = false, useField = useWiredMediaRefField, }, _optional = {}) {
|
|
7
8
|
const t = useSeoCopy();
|
|
8
|
-
const { Picker = MediaPickerBridge
|
|
9
|
-
const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl,
|
|
10
|
-
|
|
9
|
+
const { Picker = MediaPickerBridge } = useMediaRefPresentation({ value });
|
|
10
|
+
const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, file, accept, onPreviewLoad, onPreviewError } = useField({ value, onChange });
|
|
11
|
+
const labelId = `${id}-label`;
|
|
12
|
+
/* One compact row: thumbnail, the file's name and size, then Choose/Remove kept together in their
|
|
13
|
+
own group so a narrow column wraps the pair as one unit instead of stranding Remove on a line of
|
|
14
|
+
its own (owner, 2026-10-15). The thumbnail used to sit under the row at 4rem, repeated per field.
|
|
15
|
+
`warning` (share-image size/byte/HTTP diagnostics) is still computed by the hook but no longer
|
|
16
|
+
painted: the owner called the yellow callout under every image "completely useless".
|
|
17
|
+
2026-10-15 (round 3): the text box is gone — for a library pick it only ever said a read-only
|
|
18
|
+
"Selected image" ("i can't change it and looks useless"), so the row names the file instead and
|
|
19
|
+
the picker is the one way in. The exact value still rides a hidden input for FormData saves. */
|
|
20
|
+
return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("span", { className: "jini-field-label", id: labelId, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", role: "group", "aria-labelledby": labelId, ...(base ? agentHandle({ handle: base }, { role: "region", label }) : {}), children: [file ? (_jsxs("span", { className: inherited ? "jini-media-ref-field-file jini-media-ref-field-file-inherited" : "jini-media-ref-field-file", children: [previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, _jsx("span", { className: "jini-media-ref-field-name", title: file.name, children: inherited ? interpolate({ template: t({ locale: locale, key: inherited === 'content' ? "Using image from page content: {name}" : "Using site default: {name}", vars: { name: file.name } }), vars: { name: file.name } }) : file.name }), file.size && !inherited ? _jsx("span", { className: "jini-media-ref-field-size", children: file.size }) : null] })) : (emptyHint && _jsx("span", { className: "jini-media-ref-field-empty", children: emptyHint })), name ? _jsx("input", { type: "hidden", id: id, name: name, value: value }) : null, _jsxs("span", { className: "jini-media-ref-field-actions", children: [_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 && !inherited ? (_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] })] }), pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
|
|
11
21
|
}
|
|
12
22
|
//# sourceMappingURL=MediaRefField.js.map
|
|
@@ -64,7 +64,7 @@ export function SitemapModal({ locale, sitemapEnabled, regenerating, onRegenerat
|
|
|
64
64
|
const { title } = useSitemapPresentation({ locale, modal });
|
|
65
65
|
const { handleRegenerate } = useSitemapModalRegenerate({ onRegenerate });
|
|
66
66
|
// Native modality keeps Tab from reaching the background, as MediaPickerDialog now does too.
|
|
67
|
-
return (_jsxs(Dialog, { open: true, title: title, onClose: () => onClose(), className: "settings-dialog
|
|
67
|
+
return (_jsxs(Dialog, { open: true, title: title, onClose: () => onClose(), className: "settings-dialog jini-domain-dialog sitemap-modal", children: [_jsx("div", { className: "sitemap-modal-header", children: _jsx(SitemapModalHeaderActions, { locale: locale, modal: modal }) }), _jsx("div", { className: "sitemap-modal-body", children: _jsx(SitemapModalBody, { locale: locale, sitemapEnabled: sitemapEnabled, modal: modal }) }), _jsx("div", { className: "widget-picker-footer", children: _jsxs("span", { className: "editor-actions", children: [_jsx("button", { type: "button", className: "btn-secondary", onClick: handleRegenerate, disabled: regenerating, ...agentHandle({ handle: "seo-sitemap-modal-regenerate" }, {
|
|
68
68
|
role: "button",
|
|
69
69
|
label: "Rebuild the cached sitemap and refresh this view",
|
|
70
70
|
}), children: actionLabel({ pending: regenerating, pendingLabel: t({ locale: locale, key: "Working…" }), idleLabel: t({ locale: locale, key: "Regenerate sitemap" }) }) }), _jsx("button", { type: "button", className: "btn-secondary", onClick: onClose, "data-jini-autofocus": "", ...agentHandle({ handle: "seo-sitemap-modal-close" }, { role: "button", label: "Close the sitemap viewer" }), children: t({ locale: locale, key: "Close" }) })] }) })] }));
|
|
@@ -3,16 +3,24 @@ import { useShareImageWarning } from './use-share-image-warning.hooks.js';
|
|
|
3
3
|
import { agentHandle } from '@jini-ai/agentic';
|
|
4
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
5
5
|
import { createMediaRefController } from '../../controllers/media-ref.controller.js';
|
|
6
|
-
import { isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
|
|
6
|
+
import { describeMediaRef, isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
|
|
7
|
+
import { useFetchQuery } from '@jini-ai/ui/fetch-query';
|
|
8
|
+
import { extensionForMimeType } from '@jini-ai/ui';
|
|
9
|
+
import { SEO_KEYS } from './SeoQuery.hooks.js';
|
|
7
10
|
import { useSeoPorts, useSeoOptions } from './SeoPorts.hooks.js';
|
|
8
11
|
export function useMediaRefField({ value, onChange, api }, { t } = {}) {
|
|
9
12
|
const change = useRef(onChange);
|
|
10
13
|
change.current = onChange;
|
|
11
14
|
const { controller, snapshot } = useController({ create: () => createMediaRefController({ onChange: value => change.current(value) }), dependencies: [] });
|
|
12
15
|
const previewUrl = api.mediaPreviewUrl ? api.mediaPreviewUrl({ value }) : resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => api.mediaOriginalUrl({ id }) });
|
|
13
|
-
const diagnostics = useShareImageWarning({ value, previewUrl, api }, { t });
|
|
16
|
+
const { naturalWidth, naturalHeight, ...diagnostics } = useShareImageWarning({ value, previewUrl, api }, { t });
|
|
17
|
+
// One cached library read shared by every image field on the screen; only a library reference
|
|
18
|
+
// needs it (a URL names itself), and a host without `listSeoMedia` falls back to the slug.
|
|
19
|
+
const list = api.listSeoMedia;
|
|
20
|
+
const media = useFetchQuery({ key: SEO_KEYS.media, fetch: () => list ? list.call(api, {}) : Promise.resolve([]) }, { enabled: Boolean(list) && isStoredMediaRef({ value }) });
|
|
14
21
|
return {
|
|
15
22
|
...diagnostics,
|
|
23
|
+
file: describeMediaRef({ value, media: media.data }, { width: naturalWidth, height: naturalHeight, t, extensionFor: extensionForMimeType }),
|
|
16
24
|
pickerOpen: snapshot?.pickerOpen ?? false,
|
|
17
25
|
openPicker: () => controller?.openPicker({}), closePicker: () => controller?.closePicker({}),
|
|
18
26
|
handleSelect: item => controller?.handleSelect({ item }), clear: () => controller?.clear({}),
|
|
@@ -29,6 +29,7 @@ export declare function useAnalyzePanel({ analysis }: {
|
|
|
29
29
|
analysis: SeoEntryAnalysis;
|
|
30
30
|
}, _optional?: Record<string, never>): {
|
|
31
31
|
sortedIssues: import("../../../core/index.js").AdminSeoIssue[];
|
|
32
|
+
tone: "warning" | "success" | "failure";
|
|
32
33
|
};
|
|
33
34
|
/** All seven defaults remain in one FormData submission; tabs never split this form. */
|
|
34
35
|
export declare function useSeoDefaults({ save }: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { SeoDefaultsIcon, SeoSitemapIcon, SeoEntriesIcon } from '../components/SeoIcons.js';
|
|
3
|
-
import { buildSeoSettingsPatch, resolveSeoTabId, sortIssuesBySeverity } from '../../rules.js';
|
|
3
|
+
import { buildSeoSettingsPatch, resolveSeoTabId, sortIssuesBySeverity, scoreTone } from '../../rules.js';
|
|
4
4
|
import { useSeoOptions } from './SeoPorts.hooks.js';
|
|
5
5
|
export { buildSeoSettingsPatch, resolveSeoTabId } from '../../rules.js';
|
|
6
6
|
export function resolveSeoTabs(_required = {}, { t = key => key } = {}) {
|
|
@@ -20,7 +20,7 @@ export function useSeoTabs({ tabId }, _optional = {}) {
|
|
|
20
20
|
return { activeTabId: resolveSeoTabId({ tabId }), tabs: resolveSeoTabs({}, { t }) };
|
|
21
21
|
}
|
|
22
22
|
export function useAnalyzePanel({ analysis }, _optional = {}) {
|
|
23
|
-
return { sortedIssues: sortIssuesBySeverity({ issues: analysis.issues }) };
|
|
23
|
+
return { sortedIssues: sortIssuesBySeverity({ issues: analysis.issues }), tone: scoreTone({ score: analysis.score }) };
|
|
24
24
|
}
|
|
25
25
|
/** All seven defaults remain in one FormData submission; tabs never split this form. */
|
|
26
26
|
export function useSeoDefaults({ save }, _optional = {}) {
|
|
@@ -8,6 +8,7 @@ export declare const SEO_KEYS: {
|
|
|
8
8
|
}, _optional?: Record<string, never>) => readonly ["seo", "entry", string];
|
|
9
9
|
choices: readonly ["seo", "choices"];
|
|
10
10
|
sitemap: readonly ["seo", "sitemap"];
|
|
11
|
+
media: readonly ["seo", "media"];
|
|
11
12
|
};
|
|
12
13
|
/** Controllers keep their draft/error contracts; these injected ports share the provider cache.
|
|
13
14
|
* Reads omit a controller's abort signal because another mounted consumer can own the same read.
|
|
@@ -7,6 +7,7 @@ export const SEO_KEYS = {
|
|
|
7
7
|
entry: ({ entryId }, _optional = {}) => ['seo', 'entry', entryId],
|
|
8
8
|
choices: ['seo', 'choices'],
|
|
9
9
|
sitemap: ['seo', 'sitemap'],
|
|
10
|
+
media: ['seo', 'media'],
|
|
10
11
|
};
|
|
11
12
|
/** Controllers keep their draft/error contracts; these injected ports share the provider cache.
|
|
12
13
|
* Reads omit a controller's abort signal because another mounted consumer can own the same read.
|
|
@@ -2,13 +2,18 @@ import { describeSeoError } from '../../models.js';
|
|
|
2
2
|
import { useFetchQuery } from '@jini-ai/ui/fetch-query';
|
|
3
3
|
import { SEO_KEYS } from './SeoQuery.hooks.js';
|
|
4
4
|
import { initialEntryPickerState } from '../../models.js';
|
|
5
|
+
import { entryPickerOptions } from '../../rules.js';
|
|
5
6
|
import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
|
|
6
7
|
export function useEntryPicker({ api }, { t, describeError } = {}) {
|
|
7
8
|
const query = useFetchQuery({ key: SEO_KEYS.choices, fetch: async () => {
|
|
8
9
|
const [posts, pages] = await Promise.all([api.listSeoPosts({}), api.listSeoPages({})]);
|
|
9
|
-
return
|
|
10
|
+
return { posts, pages };
|
|
10
11
|
} });
|
|
11
|
-
|
|
12
|
+
const data = query.data;
|
|
13
|
+
return { ...initialEntryPickerState,
|
|
14
|
+
entries: data ? [...data.posts, ...data.pages] : null,
|
|
15
|
+
options: data ? entryPickerOptions(data, { t }) : null,
|
|
16
|
+
error: query.error ? (describeError ?? describeSeoError)({ error: query.error, fallback: t?.('failed to load entries') ?? 'failed to load entries' }) : null };
|
|
12
17
|
}
|
|
13
18
|
export function useWiredEntryPicker(_required = {}, _optional = {}) {
|
|
14
19
|
const { seoApi: api } = useSeoPorts();
|