diff --git a/.changeset/rfc45-step6b-studio-builder.md b/.changeset/rfc45-step6b-studio-builder.md new file mode 100644 index 00000000..817a4f30 --- /dev/null +++ b/.changeset/rfc45-step6b-studio-builder.md @@ -0,0 +1,10 @@ +--- +"@cloudhal/api": patch +--- + +RFC-0045 rollout step 6b: Studio's fields builder, delete dialog and publish warnings for reference and repeater fields. Both types stay refused on every production write path until step 7, and the builder offers them only where the server takes them, so production Studio is unchanged. + +- **`/api/instance`** reports `features.referenceFields`: true when the store accepts reference and repeater fields (`SiteGraphStoreOptions.fieldTypes`, MCP's rule). +- **`DELETE /api/sites/:siteId/entries/:entryId?rev=&dryRun=1`** runs the store's delete plan and writes nothing: `{ dryRun: true }`, or the plan's refusal (`entry_referenced` with its referrers, `field_operation_running`, …). +- **New read-only route `GET /api/sites/:siteId/entries/reference-issues`** (`content:read`, in the playground's allow-list): `referenceIssues` over the working copy, the first 50 with each holder's title, and the total. +- **Studio** (not published): reference and list fields in the builder (target collections, "Several", the maximum and conditions; item fields with up/down and ⋯, the maximum and the item name), sub-field operations and reference/list targets in "Change type…", a cleared finish for a run stuck on values that stopped converting, the delete dialog's reference rules (off while published entries show the last entry, drafts that will show nothing, the language that loses the card), publish warnings, and worded API errors for the RFC-0045 refusals. diff --git a/apps/studio/src/components/EntryDeleteDialog.tsx b/apps/studio/src/components/EntryDeleteDialog.tsx new file mode 100644 index 00000000..64e416d8 --- /dev/null +++ b/apps/studio/src/components/EntryDeleteDialog.tsx @@ -0,0 +1,97 @@ +import type { ContentEntry } from "@cloudhal/core-contracts"; +import { DeleteResource } from "@cloudhal/ui"; +import { useQuery } from "@tanstack/react-query"; +import { lazy, Suspense, useState } from "react"; +import { useT } from "../i18n/index.tsx"; +import { ApiRequestError } from "../lib/api/http.ts"; +import type { ReferencedDetails } from "../lib/api/references.ts"; +import { api } from "../lib/api.ts"; +import type { MenuReference } from "../lib/menus.ts"; +import { deleteResourceProps } from "./deleteLabels.ts"; +import { referenced } from "./EntryFieldCards.tsx"; +import { MenuDeleteNotice } from "./Menus.tsx"; + +// RFC-0045 §5: the references part loads only where some field can reference this collection. +const DeleteReferences = lazy(() => + import("./fields/DeleteReferences.tsx").then((m) => ({ default: m.DeleteReferences })), +); + +/** + * The entry editor's delete (DESIGN.md §2d): `DeleteResource` with the menus that will hide the + * entry and, where references can point at it (RFC-0045 §5), what the store says about its + * referrers. Delete stays off while that check runs and while the store refuses + * (`entry_referenced`); a failed check leaves it on, since the delete itself is guarded. A refusal + * of the delete itself shows in the dialog: its referrers as the blocked state, any other refusal + * (`field_operation_running`, …) in its words. + */ +export function EntryDeleteDialog({ + open, + onOpenChange, + siteId, + entry, + menuRefs, + isDeleting, + onDelete, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + siteId: string; + /** The entry to delete; none while the dialog is closed (a list's row). */ + entry: ContentEntry | undefined; + menuRefs: MenuReference[] | undefined; + isDeleting: boolean; + onDelete: () => Promise; +}) { + const t = useT(); + const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) }); + const checks = + site.data !== undefined && entry !== undefined && referenced(site.data, entry.collection); + // Undefined while the check runs; then whether the store refuses the delete. + const [blocked, setBlocked] = useState(); + // The real delete's refusal: its referrers when the check went stale, else its words. + const [refused, setRefused] = useState<{ details?: unknown; error?: unknown }>(); + // A refusal belongs to one opening of the dialog for one revision: reopening, or a new revision, + // asks the store again (the editor keeps the dialog mounted for the same entry). + const session = open && entry ? `${entry.id}:${entry.rev}` : undefined; + const [shown, setShown] = useState(session); + if (shown !== session) { + setShown(session); + setRefused(undefined); + } + const deleteNow = async () => { + setRefused(undefined); + try { + await onDelete(); + } catch (e) { + const referenced = e instanceof ApiRequestError && e.body.error === "entry_referenced"; + setRefused(referenced ? { details: e.body.details } : { error: e }); + } + }; + return ( + + + {checks && site.data && entry && ( + + + + )} + + } + deleteDisabled={(checks && blocked !== false) || refused?.details !== undefined} + isDeleting={isDeleting} + onDelete={deleteNow} + /> + ); +} diff --git a/apps/studio/src/components/EntryFieldCards.tsx b/apps/studio/src/components/EntryFieldCards.tsx index 45e63263..b9c5e483 100644 --- a/apps/studio/src/components/EntryFieldCards.tsx +++ b/apps/studio/src/components/EntryFieldCards.tsx @@ -13,7 +13,7 @@ const UsedByCard = lazy(() => ); /** True when some field of the site, or a repeater's sub-field, can reference `collection`. */ -function referenced(site: Site, collection: string): boolean { +export function referenced(site: Site, collection: string): boolean { return Object.values(site.collections ?? {}).some((settings) => (settings?.fields ?? []).some( (def) => diff --git a/apps/studio/src/components/SitePublish.tsx b/apps/studio/src/components/SitePublish.tsx index 8d32d96f..b305eac4 100644 --- a/apps/studio/src/components/SitePublish.tsx +++ b/apps/studio/src/components/SitePublish.tsx @@ -8,7 +8,7 @@ import { } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useT } from "../i18n/index.tsx"; import { api } from "../lib/api.ts"; import { apiErrorText } from "../lib/api-errors.ts"; @@ -17,6 +17,11 @@ import { PendingBadge, pendingText, usePendingChanges } from "./PendingChanges.t import { usePublishMode, usePublishSite } from "./Publish.tsx"; import { useToast } from "./Toast.ts"; +// RFC-0045: the publish dialog's reference warnings, a lazy chunk that loads with the dialog. +const ReferenceWarnings = lazy(() => + import("./fields/PublishWarnings.tsx").then((m) => ({ default: m.ReferenceWarnings })), +); + /** * The site surface's Publish split button (top bar, every site screen): Publish → `ApprovalDialog` * → snapshot + publish; the menu takes a snapshot without publishing (DESIGN.md §1, §2d). It follows @@ -121,6 +126,9 @@ export function SitePublish({ onConfirm={() => publish.mutate({ label: t("snapshots.publishedFromStudio") })} > setOpen(false)} /> + + setOpen(false)} /> + ); diff --git a/apps/studio/src/components/fields/DeleteReferences.tsx b/apps/studio/src/components/fields/DeleteReferences.tsx new file mode 100644 index 00000000..77efd5cc --- /dev/null +++ b/apps/studio/src/components/fields/DeleteReferences.tsx @@ -0,0 +1,304 @@ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { memberGroup } from "@cloudhal/core-ts/fields"; +import { Banner, UsedByList, type UsedByRow } from "@cloudhal/ui"; +import { LinkBreak, Prohibit } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { formatList, type TFunction, useI18n } from "../../i18n/index.tsx"; +import { ApiRequestError } from "../../lib/api/http.ts"; +import { + type ReferenceCard, + type ReferencedDetails, + type Referrer, + referencesApi, +} from "../../lib/api/references.ts"; +import { collectionName } from "../../lib/collections.ts"; +import { entryHref } from "../../lib/entry-href.ts"; +import { fieldCollections, referrerFieldWords } from "../../lib/field-references.ts"; +import { languageName } from "../../lib/languages.ts"; + +type FT = TFunction<"fields">; + +/** The store's answer to "can this entry be deleted?": its refusal's details, or none. */ +async function deleteCheck(entry: ContentEntry): Promise { + try { + await referencesApi.deleteCheck(entry); + return null; + } catch (e) { + if (e instanceof ApiRequestError && e.body.error === "entry_referenced") + return e.body.details as ReferencedDetails; + throw e; + } +} + +function statusWords(t: FT) { + return { + draft: t("status.draft"), + review: t("status.review"), + published: t("status.published"), + archived: t("status.archived"), + }; +} + +/** + * The delete dialog's references (RFC-0045 §5), from the store itself: + * - its plan (`DELETE …?dryRun=1`) refuses with `entry_referenced` while a published entry shows + * a reference and this is the group's last usable entry: Delete stays off, the published + * referrers are listed, and the dialog says that archiving this entry doesn't release it; + * - otherwise, when no other entry of the group is left, draft referrers are listed as "Will show + * nothing for this field"; when others are left, it names the language whose pages lose the card. + * `onBlocked` tells the dialog: undefined while asking, then whether the store refuses. + */ +export function DeleteReferences({ + site, + entry, + open, + refused, + onBlocked, +}: { + site: Site; + entry: ContentEntry; + open: boolean; + /** The real delete's own refusal (the check went stale): shown as the blocked state. */ + refused?: ReferencedDetails | undefined; + onBlocked: (blocked: boolean | undefined) => void; +}) { + const { t, locale } = useI18n("fields"); + const group = memberGroup(entry); + const check = useQuery({ + queryKey: ["delete-check", site.id, entry.id, entry.rev], + queryFn: () => deleteCheck(entry), + enabled: open && !refused, + retry: false, + gcTime: 0, + }); + const details = refused ?? check.data ?? null; + const referrers = useQuery({ + // Read again when a late refusal arrives: the list read before it may miss the blockers. + queryKey: ["entry-referrers", site.id, entry.id, "delete", refused ? "refused" : "checked"], + queryFn: () => referencesApi.referrers(site.id, entry.id, { limit: 100 }), + enabled: open && (details !== null || check.data === null), + }); + // Its own key: the editor caches `…/groups` answers by group id in another shape. + const members = useQuery({ + queryKey: ["delete-group-members", site.id, group], + queryFn: () => referencesApi.groups(site.id, [group]), + enabled: open && check.data === null && !refused, + }); + const report = useRef(onBlocked); + report.current = onBlocked; + const blocked = refused + ? true + : check.isPending + ? undefined + : check.isError + ? false + : check.data !== null; + useEffect(() => report.current(blocked), [blocked]); + if (details) + return ( + + ); + if (!referrers.data || !members.data) return null; + const rows = referrers.data.referrers; + if (rows.length === 0) return null; + // As the store decides, per referring field: does another entry of the group stay in a + // collection that field allows? Those that keep one lose this language's card; the others show + // nothing. Both can be true at once. + const remaining = (members.data.groups[group] ?? []).filter( + (m) => m.id !== entry.id && m.status !== "archived", + ); + const kept = (r: Referrer) => { + const allowed = fieldCollections(site, r.collection, r.field); + return remaining.filter((m) => allowed.includes(m.collection)); + }; + const lost = rows.filter((r) => kept(r).length === 0); + const others = rows.flatMap(kept); + return ( + <> + + {others.length > 0 && ( + + )} + + ); +} + +/** Where a referrer's value sits: "Events · Speakers: Person". */ +function whereOf(t: FT, site: Site, r: { collection: string; field: string }, locale: string) { + const words = referrerFieldWords(site, r.collection, r.field, locale); + return t("fields.delete.where", { + collection: collectionName(site, r.collection, locale, t("nav.articles")), + field: words.sub + ? t("fields.usedBy.subField", { field: words.field, sub: words.sub }) + : words.field, + }); +} + +/** + * The store refuses: the entries that keep it, each with its real status and the published + * language versions that show its value (a draft original whose translation is published), and + * why archiving doesn't help. Until the referrers are read, the refusal's own list stands in. + */ +function Blocked({ + t, + site, + details, + referrers, + more, + locale, +}: { + t: FT; + site: Site; + details: ReferencedDetails; + referrers: readonly Referrer[] | undefined; + /** The referrers' list has another page. */ + more: boolean; + locale: string; +}) { + const published = (r: Referrer) => r.shownBy.filter((m) => m.status === "published"); + // The referrers read now; if they no longer hold a blocker (read before a late refusal), the + // refusal's own list stands in. + const read = referrers?.filter((r) => r.status === "published" || published(r).length > 0); + const blocking = read?.length ? read : undefined; + const rows: UsedByRow[] = blocking + ? blocking.map((r) => { + const shown = published(r); + return { + id: `${r.id} ${r.field}`, + title: r.title, + href: entryHref(site.id, r.id, r.collection), + detail: whereOf(t, site, r, locale), + status: r.status, + ...(shown.length + ? { + note: t("fields.delete.shownByPublished", { + languages: formatList( + locale, + shown.map((m) => languageName(locale, m.locale)), + ), + }), + } + : {}), + }; + }) + : details.referrers.map((r) => ({ + id: `${r.id} ${r.field}`, + title: r.title, + href: entryHref(site.id, r.id, r.collection), + detail: whereOf(t, site, r, locale), + status: "published", + })); + // The store counts every blocking row; the list read here stops at 100. + const total = Math.max(details.total, blocking?.length ?? 0); + return ( +
+ } + title={t("fields.delete.blockedTitle", { count: total })} + description={t("fields.delete.blocked")} + /> + + {(total > rows.length || more) && ( + + )} +
+ ); +} + +/** The last usable entry of its group goes: drafts that name it will show nothing. */ +function DraftsShowNothing({ + t, + site, + rows, + locale, +}: { + t: FT; + site: Site; + rows: readonly Referrer[]; + locale: string; +}) { + const drafts = rows.filter((r) => r.status !== "published"); + if (drafts.length === 0) return null; + return ( +
+ } + title={t("fields.delete.draftsTitle", { count: drafts.length })} + description={t("fields.delete.drafts")} + /> + ({ + id: `${r.id} ${r.field}`, + title: r.title, + href: entryHref(site.id, r.id, r.collection), + detail: whereOf(t, site, r, locale), + status: r.status, + note: t("fields.delete.showsNothing"), + }))} + labels={listLabels(t)} + /> +
+ ); +} + +/** Other entries of the group stay: pages in this entry's language lose its card. */ +function LocaleLoses({ + t, + site, + entry, + others, + locale, +}: { + t: FT; + site: Site; + entry: ContentEntry; + others: readonly ReferenceCard[]; + locale: string; +}) { + const language = languageName(locale, entry.locale); + const fallback = + site.localization?.fallback === "default-with-notice" && + entry.locale !== site.defaultLocale && + others.some((m) => m.locale === site.defaultLocale); + return ( +
+ } + title={t("fields.delete.localeTitle", { language })} + description={ + fallback + ? t("fields.delete.localeFallback", { + language, + fallback: languageName(locale, site.defaultLocale), + }) + : t("fields.delete.locale", { language }) + } + /> +
+ ); +} + +function listLabels(t: FT) { + return { + entry: t("fields.usedBy.entry"), + status: t("fields.usedBy.status"), + caption: t("fields.delete.caption"), + statuses: statusWords(t), + }; +} diff --git a/apps/studio/src/components/fields/FieldDialog.tsx b/apps/studio/src/components/fields/FieldDialog.tsx index eb002f5d..18242e81 100644 --- a/apps/studio/src/components/fields/FieldDialog.tsx +++ b/apps/studio/src/components/fields/FieldDialog.tsx @@ -1,5 +1,5 @@ -import type { FieldDefinition } from "@cloudhal/core-contracts"; -import { FIELD_LIMITS, FIELD_TYPES } from "@cloudhal/core-ts/fields"; +import type { FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { FIELD_LIMITS, FIELD_TYPES, REFERENCE_FIELD_TYPES } from "@cloudhal/core-ts/fields"; import { Button, Input, InsetPanel, LayerDialog, Select, Text } from "@cloudhal/ui"; import { Plus, Trash } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; @@ -15,9 +15,13 @@ import { withLabel, withOptionLabel, } from "../../lib/field-form.ts"; +import { SUB_TYPES } from "../../lib/field-form-stage4.ts"; import { type FieldType, localizedByDefault } from "../../lib/fields.ts"; import { languageName } from "../../lib/languages.ts"; +import { listFieldDefs } from "../../lib/listings.ts"; import { SwitchField } from "../TaxonomySettingsDialog.tsx"; +import { ReferenceFields } from "./ReferenceFieldsForm.tsx"; +import { RepeaterFields } from "./SubFieldList.tsx"; type FT = TFunction<"fields">; type SetForm = (next: FieldForm) => void; @@ -31,6 +35,13 @@ export interface FieldFormContext { uiLocale: string; isNew: boolean; problems: FormProblems; + /** RFC-0045: the site (target collections) and whether its server takes the two new types. */ + site: Site; + referenceTypes: boolean; + /** A repeater's sub-field: sub-field types, no languages switch, search or "Several". */ + sub?: boolean; + /** A type change's target (the operation dialog): no conditions, which are a free edit after. */ + operation?: boolean; } /** Label and help per site language; the default language's label is required. */ @@ -77,16 +88,8 @@ function LabelFields({ } /** A select's options: value (stored, never shown to readers) and a label per language. */ -export function OptionFields({ - t, - form, - set, - locales, - defaultLocale, - uiLocale, - isNew, - problems, -}: FieldFormContext) { +export function OptionFields(c: FieldFormContext) { + const { t, form, set, locales, defaultLocale, uiLocale, isNew, problems } = c; const many = locales.length > 1; return (
@@ -171,22 +174,56 @@ export function OptionFields({ {t("fields.dialog.addOption")}
- set({ ...form, multiple })} - disabled={!isNew} - /> + {!c.sub && ( + set({ ...form, multiple })} + disabled={!isNew} + /> + )} ); } -/** What only some types have: length and lines, bounds, search, options. */ -function TypeFields(c: FieldFormContext) { +/** + * What only some types have: length and lines, bounds, search, options, a reference's targets and + * a repeater's sub-fields. The operation dialog draws a type change's target with it too. + */ +export function TypeFields(c: FieldFormContext) { const { t, form, set, problems } = c; if (form.type === "select") return ; + if (form.type === "reference") + return ( + + ); + if (form.type === "repeater") + return ( + ( + <> + + + + )} + /> + ); return ( <> {form.type === "text" && ( @@ -245,7 +282,7 @@ function TypeFields(c: FieldFormContext) { /> )} - {(form.type === "text" || form.type === "richText") && ( + {!c.sub && (form.type === "text" || form.type === "richText") && ( [type, t(`fields.types.${type}`)])); + // RFC-0045: reference and repeater only where the server takes them (a saved one still shows). + const offered: readonly FieldType[] = c.sub + ? SUB_TYPES + : c.referenceTypes || REFERENCE_FIELD_TYPES.includes(form.type as "reference") + ? [...FIELD_TYPES, ...REFERENCE_FIELD_TYPES] + : FIELD_TYPES; + const types = Object.fromEntries(offered.map((type) => [type, t(`fields.types.${type}`)])); return ( <> setInputs({ ...inputs, key })} - error={props.keyError} - maxLength={40} - disabled={props.disabled} - /> - ); - if (target.kind !== "change_field_type") return null; - const types = targetTypes(target.def.type); - return ( -
- + setInputs(freshInputs(target, v === WHOLE ? "" : (v as string), ctx)) + } + items={{ + [WHOLE]: t("fields.operation.wholeField"), + ...(target.def.type === "repeater" + ? Object.fromEntries( + target.def.fields.map((s) => [ + s.key, + fieldLabel(s, props.uiLocale, props.defaultLocale), + ]), + ) + : {}), + }} + data-testid="field-operation-sub" + /> + )} + {target.kind === "rename_field" && ( + setInputs({ ...inputs, key })} + error={props.keyError} + maxLength={40} + disabled={props.disabled} + /> + )} + {target.kind === "change_field_type" && ( + <> + set({ ...form, maxItems })} + error={ + problems.maxItems + ? t(`${d}.errors.maxItems`, { max: STAGE4_LIMITS.maxItems }) + : undefined + } + min={1} + max={STAGE4_LIMITS.maxItems} + /> + )} + {problems.where && ( + + {t(`${d}.errors.where`)} + + )} + {!noConditions && ( + + )} + + )} +
+ ); +} + +/** "Only entries where": conditions a newly chosen target meets (one target collection only). */ +function Conditions({ + target, + targetName, + defs, + site, + form, + set, +}: { + target: string | undefined; + targetName: string; + defs: ReturnType; + site: Site; + form: FieldForm; + set: (next: FieldForm) => void; +}) { + const { t, locale } = useI18n("fields"); + const d = "fields.dialog.reference" as const; + const label = (def: (typeof defs)[number]) => fieldLabel(def, locale, site.defaultLocale); + const full = form.where.length >= STAGE4_LIMITS.conditions; + return ( +
+
+ + {t(`${d}.where`)} + + + {!target + ? t(`${d}.whereOneCollection`) + : defs.length === 0 + ? t(`${d}.whereNoListFields`, { collection: targetName }) + : t(`${d}.whereHint`)} + +
+ {target && + form.where.map((cond, i) => ( + + set({ ...form, where: form.where.map((w, j) => (j === i ? next : w)) }) + } + onRemove={() => set({ ...form, where: form.where.filter((_, j) => j !== i) })} + /> + ))} + {target && defs.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/studio/src/components/fields/StuckOperation.tsx b/apps/studio/src/components/fields/StuckOperation.tsx new file mode 100644 index 00000000..fbed76cb --- /dev/null +++ b/apps/studio/src/components/fields/StuckOperation.tsx @@ -0,0 +1,45 @@ +import { Banner, Checkbox } from "@cloudhal/ui"; +import { Warning } from "@phosphor-icons/react"; +import type { TFunction } from "../../i18n/index.tsx"; +import type { Stuck } from "./useFieldOperation.ts"; + +/** + * A run or revert that can't finish (RFC-0045 §7): entries whose values stopped converting while it + * ran — a target archived or deleted, a second entry with the same title. Finishing drops those + * values (`onInvalid: "clear"`), which the snapshot taken before keeps; the person ticks the + * destructive confirmation first, and the dialog's primary button becomes "Clear and finish". + */ +export function StuckOperation({ + t, + stuck, + confirmed, + onConfirm, +}: { + t: TFunction<"fields">; + stuck: Stuck; + confirmed: boolean; + onConfirm: (v: boolean) => void; +}) { + const count = stuck.entries.length; + return ( +
+ {/* Announced as it appears: the run stopped and waits for the person. */} +
+ } + title={t("fields.operation.stuckTitle", { count })} + description={t( + stuck.reverting ? "fields.operation.stuckRevert" : "fields.operation.stuckRun", + )} + /> +
+ onConfirm(v)} + data-testid="field-operation-stuck-confirm" + /> +
+ ); +} diff --git a/apps/studio/src/components/fields/SubFieldList.tsx b/apps/studio/src/components/fields/SubFieldList.tsx new file mode 100644 index 00000000..f8d146a1 --- /dev/null +++ b/apps/studio/src/components/fields/SubFieldList.tsx @@ -0,0 +1,422 @@ +import { fieldLabel } from "@cloudhal/core-ts/fields"; +import { Badge, Button, DropdownMenu, Input, LayerDialog, Select, Text } from "@cloudhal/ui"; +import { ArrowDown, ArrowUp, DotsThree, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; +import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { type TFunction, useI18n } from "../../i18n/index.tsx"; +import { + emptyForm, + type FieldForm, + type FormProblems, + formProblems, + hasProblems, +} from "../../lib/field-form.ts"; +import { + moveSub, + namesItems, + STAGE4_LIMITS, + type SubFieldForm, + subId, +} from "../../lib/field-form-stage4.ts"; + +type FT = TFunction<"fields">; +const NONE = "__none"; + +/** What the sub-field dialog needs to draw a sub-field's form (FieldDialog's own parts). */ +export type RenderSubForm = (c: { + form: FieldForm; + set: (next: FieldForm) => void; + isNew: boolean; + problems: FormProblems; +}) => ReactNode; + +interface Editing { + id: string; + draft: FieldForm; + isNew: boolean; + adding: boolean; +} + +type Focus = { id: string; at: "up" | "down" | "menu" } | "add" | null; + +/** + * A repeater's own settings in the fields builder (RFC-0045 §5): its sub-fields as rows (label, + * key in mono, type, "Not saved"), each with up and down and a ⋯ menu (Edit…; Remove for one not + * saved yet: a saved sub-field is renamed, retyped or removed through the field's operations), "Add + * sub-field" (up to ten), the maximum number of items and the sub-field that names them. Focus + * stays on a moved row and goes to the next row, or "Add sub-field", after a removal. + */ +export function RepeaterFields({ + form, + set, + problems, + defaultLocale, + uiLocale, + locales, + renderSubForm, +}: { + form: FieldForm; + set: (next: FieldForm) => void; + problems: FormProblems; + defaultLocale: string; + uiLocale: string; + locales: readonly string[]; + renderSubForm: RenderSubForm; +}) { + const { t } = useI18n("fields"); + const d = "fields.dialog.repeater" as const; + // `isNew`: not saved yet (key and type editable); `adding`: not in the list yet. + const [editing, setEditing] = useState(); + const root = useRef(null); + const focus = useRef(null); + // Every write reads the latest form: the nested dialog applies after the outer one re-rendered. + const latest = useRef(form); + latest.current = form; + useLayoutEffect(() => { + const want = focus.current; + focus.current = null; + if (!want) return; + if (want === "add") + return void root.current?.querySelector("[data-sub-add]")?.focus(); + const row = root.current?.querySelector(`[data-sub-row="${want.id}"]`); + // The same arrow, else the other one (at an end), else the row's menu. + const pick = (selector: string) => row?.querySelector(selector); + ( + pick(`[data-sub-move="${want.at}"]:not([disabled])`) ?? + (want.at === "menu" ? undefined : pick("[data-sub-move]:not([disabled])")) ?? + pick("[data-sub-menu]") + )?.focus(); + }); + const subs = form.subFields; + const full = subs.length >= STAGE4_LIMITS.subFields; + const remove = (id: string) => { + const i = subs.findIndex((s) => s.id === id); + const next = subs[i + 1] ?? subs[i - 1]; + focus.current = next ? { id: next.id, at: "menu" } : "add"; + const left = subs.filter((s) => s.id !== id); + const gone = subs[i]?.form.key; + set({ ...form, subFields: left, itemLabel: form.itemLabel === gone ? "" : form.itemLabel }); + }; + const named = subs.filter((s) => namesItems(s.form) && s.form.key); + const label = (s: SubFieldForm) => + s.form.labels[defaultLocale]?.label.trim() || + (s.form.key ? fieldLabel({ key: s.form.key, labels: {} }, uiLocale, defaultLocale) : ""); + return ( +
+
+
+ + {t(`${d}.subFields`)} + + + {t(`${d}.subFieldsHint`, { max: STAGE4_LIMITS.subFields })} + + {problems.subFields && ( + + {t(`${d}.errors.subFields.${problems.subFields}`)} + + )} +
+ {subs.length > 0 && ( +
+ {subs.map((s, i) => ( + { + focus.current = { id: s.id, at: by < 0 ? "up" : "down" }; + set({ ...form, subFields: moveSub(subs, s.id, by) }); + }} + onEdit={() => + setEditing({ id: s.id, draft: s.form, isNew: !s.saved, adding: false }) + } + onRemove={() => remove(s.id)} + /> + ))} +
+ )} +
+ +
+
+ ({ id: n.id, key: n.form.key, label: label(n) }))} + /> + setEditing(undefined)} + taken={(id) => latest.current.subFields.filter((s) => s.id !== id).map((s) => s.form.key)} + defaultLocale={defaultLocale} + renderSubForm={renderSubForm} + onApply={(id, draft) => { + const now = latest.current; + const was = now.subFields.find((s) => s.id === id)?.form; + const subFields = was + ? now.subFields.map((s) => (s.id === id ? { ...s, form: draft } : s)) + : [...now.subFields, { id, saved: false, form: draft }]; + // The item name follows a renamed item field, and lets go of one that can't name items. + const named = was?.key === now.itemLabel; + const itemLabel = !named ? now.itemLabel : namesItems(draft) ? draft.key : ""; + set({ ...now, subFields, itemLabel }); + }} + /> +
+ ); +} + +/** A list's maximum number of items and the item field that names each item. */ +function RepeaterLimits({ + form, + set, + problems, + named, +}: { + form: FieldForm; + set: (next: FieldForm) => void; + problems: FormProblems; + /** The item fields that can name items, by their stable row id. */ + named: { id: string; key: string; label: string }[]; +}) { + // Options are the rows' ids, not their keys: renaming the chosen field keeps it chosen. + const byId = new Map(named.map((n) => [n.id, n.key])); + const chosen = named.find((n) => n.key === form.itemLabel)?.id; + const { t } = useI18n("fields"); + const d = "fields.dialog.repeater" as const; + return ( +
+ set({ ...form, maxItems })} + error={ + problems.maxItems ? t(`${d}.errors.maxItems`, { max: STAGE4_LIMITS.maxItems }) : undefined + } + min={1} + max={STAGE4_LIMITS.maxItems} + /> + + ); if (def.type === "date" || def.type === "datetime") return (
diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index d2e56ad7..e09abbeb 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4bc35bf389a3fc55 +// hash: a31d85e0356d6842 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -715,6 +715,21 @@ export const messages: Messages = { "لا تزال أقسام قوائم المحتوى تصفّي أو ترتّب حسب هذا الحقل. غيّر تلك الصفحات أولًا.", gates_unsupported: "لا يدعم الخادم المباشر لهذا الموقع هذا بعد. وزّع الموقع أولًا، ثم أعد المحاولة.", + entry_referenced: + "لا تزال إدخالات منشورة تعرض هذا الإدخال في حقل مرجع. ألغِ نشرها أو غيّرها أولًا.", + reference_target_moved: + "تعرض إدخالات منشورة هذا الإدخال من مجموعته الحالية. ألغِ نشرها أو غيّرها أولًا.", + field_operation_running: + "يجري تغيير حقل يحوّل القيم إلى مراجع لهذه المجموعة. انتظر حتى ينتهي.", + unknown_entry: "لا يمكن استخدام إدخال مختار: فقد حُذف أو أُرشف أو يقع في مجموعة أخرى.", + fields_unsupported: "لا يدعم هذا الخادم الحقول من نوع مرجع أو قائمة بعد.", + translation_group_in_use: + "لا تزال ترجمات تابعة لهذا الإدخال. انقلها أو افصلها قبل تغيير مجموعة الترجمة الخاصة به.", + too_many_references: "يمكن أن يحتوي الإدخال على 100 مرجع على الأكثر في جميع حقوله.", + field_key_in_use: "توجد إدخالات لها قيم بالفعل تحت هذا المفتاح. اختر مفتاحًا آخر.", + reference_target_unknown: "يشير حقل مرجع إلى مجموعة غير موجودة في هذا الموقع.", + translation_shape_mismatch: + "يجب أن تحتوي القائمة المترجمة على عدد العناصر نفسه الموجود في قائمة الأصل. أرسل القائمة كاملة.", network: "تعذّر الوصول إلى الخادم. تحقّق من اتصالك وأعد المحاولة.", status: "فشل الطلب (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index d1fe5be2..5a53d4bc 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fee1e4da7ca5d8d9 +// hash: cb9dea44970438c3 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -746,6 +746,25 @@ export const messages: Messages = { "Inhaltslisten-Abschnitte filtern oder sortieren noch nach diesem Feld. Ändere zuerst diese Seiten.", gates_unsupported: "Der Live-Server dieser Website unterstützt das noch nicht. Deploye zuerst die Website und versuche es dann erneut.", + entry_referenced: + "Veröffentlichte Einträge zeigen diesen Eintrag noch in einem Verweisfeld. Heb zuerst ihre Veröffentlichung auf oder ändere sie.", + reference_target_moved: + "Veröffentlichte Einträge zeigen diesen Eintrag aus seiner aktuellen Sammlung. Heb zuerst ihre Veröffentlichung auf oder ändere sie.", + field_operation_running: + "Eine Feldänderung wandelt gerade Werte in Verweise auf diese Sammlung um. Warte, bis sie fertig ist.", + unknown_entry: + "Ein gewählter Eintrag kann nicht verwendet werden: Er wurde gelöscht oder archiviert oder liegt in einer anderen Sammlung.", + fields_unsupported: "Dieser Server unterstützt noch keine Felder vom Typ Verweis oder Liste.", + translation_group_in_use: + "Zu diesem Eintrag gehören noch Übersetzungen. Verschiebe sie oder löse ihre Verknüpfung, bevor du seine Gruppe änderst.", + too_many_references: + "Ein Eintrag kann über alle seine Felder hinweg höchstens 100 Verweise enthalten.", + field_key_in_use: + "Einträge haben unter diesem Schlüssel bereits Werte. Wähl einen anderen Schlüssel.", + reference_target_unknown: + "Ein Verweisfeld nennt eine Sammlung, die es auf dieser Website nicht gibt.", + translation_shape_mismatch: + "Die übersetzte Liste muss so viele Elemente haben wie die des Originals. Sende die ganze Liste.", network: "Der Server ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.", status: "Die Anfrage ist fehlgeschlagen (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index 0b9eac02..d94e939a 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -697,6 +697,22 @@ export const en = { "Content list sections still filter or sort by this field. Change those pages first.", gates_unsupported: "This site's live server doesn't support this yet. Deploy the site first, then try again.", + entry_referenced: + "Published entries still show this entry in a reference field. Unpublish or change them first.", + reference_target_moved: + "Published entries show this entry from its current collection. Unpublish or change them first.", + field_operation_running: + "A field change is turning values into references to this collection. Wait until it finishes.", + unknown_entry: + "A chosen entry can't be used: it was deleted, archived or is in another collection.", + fields_unsupported: "This server doesn't take reference or list fields yet.", + translation_group_in_use: + "Translations still belong to this entry. Move or unlink them before changing its group.", + too_many_references: "An entry can hold at most 100 references across its fields.", + field_key_in_use: "Entries already hold values under this key. Choose another key.", + reference_target_unknown: "A reference field names a collection this site doesn't have.", + translation_shape_mismatch: + "The translated list must have as many items as the original's. Send the whole list.", network: "Can't reach the server. Check your connection and try again.", status: "The request failed (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index c20c5cec..970d5946 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 02de14595f0d2be5 +// hash: 0a9c239a3e8a400e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -743,6 +743,24 @@ export const messages: Messages = { "Hay secciones de lista de contenido que aún filtran u ordenan por este campo. Cambia primero esas páginas.", gates_unsupported: "El servidor en vivo de este sitio aún no admite esto. Despliega primero el sitio y vuelve a intentarlo.", + entry_referenced: + "Hay entradas publicadas que aún muestran esta entrada en un campo de referencia. Despublícalas o cámbialas primero.", + reference_target_moved: + "Hay entradas publicadas que muestran esta entrada desde su colección actual. Despublícalas o cámbialas primero.", + field_operation_running: + "Un cambio de campo está convirtiendo valores en referencias a esta colección. Espera a que termine.", + unknown_entry: + "No se puede usar una entrada elegida: se eliminó, se archivó o está en otra colección.", + fields_unsupported: "Este servidor aún no admite campos de referencia ni de lista.", + translation_group_in_use: + "Aún hay traducciones que pertenecen a esta entrada. Muévelas o desvincúlalas antes de cambiar su grupo.", + too_many_references: + "Una entrada puede tener como máximo 100 referencias entre todos sus campos.", + field_key_in_use: "Ya hay entradas con valores bajo esta clave. Elige otra clave.", + reference_target_unknown: + "Un campo de referencia nombra una colección que este sitio no tiene.", + translation_shape_mismatch: + "La lista traducida debe tener tantos elementos como la del original. Envía la lista completa.", network: "No se puede conectar con el servidor. Comprueba tu conexión y vuelve a intentarlo.", status: "La solicitud falló (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index 0a642776..c6c78912 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: c28b24eec4031b20 +// hash: c5bb97dd60c151ef import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -720,6 +720,22 @@ export const messages: Messages = { "بخش‌های فهرست محتوا هنوز بر اساس این فیلد فیلتر یا مرتب می‌کنند. ابتدا آن صفحه‌ها را تغییر دهید.", gates_unsupported: "سرور زندهٔ این سایت هنوز از این پشتیبانی نمی‌کند. ابتدا سایت را مستقر کنید و سپس دوباره تلاش کنید.", + entry_referenced: + "ورودی‌های منتشرشده هنوز این ورودی را در یک فیلد ارجاع نشان می‌دهند. ابتدا انتشارشان را لغو کنید یا آن‌ها را تغییر دهید.", + reference_target_moved: + "ورودی‌های منتشرشده این ورودی را از مجموعهٔ فعلی‌اش نشان می‌دهند. ابتدا انتشارشان را لغو کنید یا آن‌ها را تغییر دهید.", + field_operation_running: + "یک تغییر فیلد در حال تبدیل مقدارها به ارجاع‌هایی به این مجموعه است. صبر کنید تا تمام شود.", + unknown_entry: + "یک ورودی انتخاب‌شده قابل استفاده نیست: حذف یا بایگانی شده یا در مجموعهٔ دیگری است.", + fields_unsupported: "این سرور هنوز فیلدهای نوع ارجاع یا فهرست را نمی‌پذیرد.", + translation_group_in_use: + "ترجمه‌هایی هنوز به این ورودی تعلق دارند. پیش از تغییر گروهش، آن‌ها را جابه‌جا یا از آن جدا کنید.", + too_many_references: "یک ورودی در همهٔ فیلدهایش روی هم حداکثر 100 ارجاع می‌تواند داشته باشد.", + field_key_in_use: "ورودی‌ها از قبل زیر این کلید مقدار دارند. کلید دیگری انتخاب کنید.", + reference_target_unknown: "یک فیلد ارجاع به مجموعه‌ای اشاره می‌کند که این سایت ندارد.", + translation_shape_mismatch: + "فهرست ترجمه‌شده باید به اندازهٔ فهرست نسخهٔ اصلی مورد داشته باشد. کل فهرست را بفرستید.", network: "دسترسی به سرور ممکن نیست. اتصال خود را بررسی کنید و دوباره تلاش کنید.", status: "درخواست ناموفق بود (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index 74f7d47e..98255dd9 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8943434737bb6f26 +// hash: 3f8ec9918498fede import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -752,6 +752,24 @@ export const messages: Messages = { "Des sections de liste de contenus filtrent ou trient encore selon ce champ. Modifiez d’abord ces pages.", gates_unsupported: "Le serveur en ligne de ce site ne prend pas encore cela en charge. Déployez d’abord le site, puis réessayez.", + entry_referenced: + "Des entrées publiées affichent encore cette entrée dans un champ de référence. Dépubliez-les ou modifiez-les d’abord.", + reference_target_moved: + "Des entrées publiées affichent cette entrée depuis sa collection actuelle. Dépubliez-les ou modifiez-les d’abord.", + field_operation_running: + "Un changement de champ convertit des valeurs en références vers cette collection. Attendez qu’il soit terminé.", + unknown_entry: + "Une entrée choisie ne peut pas être utilisée : elle a été supprimée, archivée ou se trouve dans une autre collection.", + fields_unsupported: "Ce serveur n’accepte pas encore les champs de type référence ou liste.", + translation_group_in_use: + "Des traductions appartiennent encore à cette entrée. Déplacez-les ou dissociez-les avant de changer son groupe.", + too_many_references: + "Une entrée peut contenir au plus 100 références, tous champs confondus.", + field_key_in_use: + "Des entrées ont déjà des valeurs sous cette clé. Choisissez une autre clé.", + reference_target_unknown: "Un champ de référence désigne une collection que ce site n’a pas.", + translation_shape_mismatch: + "La liste traduite doit avoir autant d’éléments que celle de l’original. Envoyez la liste entière.", network: "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.", status: "La requête a échoué (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index e92a355c..e182126d 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -263,11 +263,26 @@ export interface MessageParams { "fields.builder.moveDown": { name: string | number }; "fields.builder.moveUp": { name: string | number }; "fields.builder.running": { count: number }; + "fields.delete.blockedTitle": { count: number }; + "fields.delete.draftsTitle": { count: number }; + "fields.delete.locale": { language: string | number }; + "fields.delete.localeFallback": { language: string | number; fallback: string | number }; + "fields.delete.localeTitle": { language: string | number }; + "fields.delete.moreReferences": { count: number }; + "fields.delete.shownByPublished": { languages: string | number }; + "fields.delete.where": { collection: string | number; field: string | number }; "fields.dialog.errors.maxLength": { max: number }; "fields.dialog.helpIn": { language: string | number }; "fields.dialog.labelIn": { language: string | number }; "fields.dialog.maxLengthHint": { max: number }; "fields.dialog.optionLabelIn": { language: string | number }; + "fields.dialog.reference.collectionsHint": { max: number }; + "fields.dialog.reference.errors.maxItems": { max: number }; + "fields.dialog.reference.maxItemsHint": { max: number }; + "fields.dialog.reference.whereNoListFields": { collection: string | number }; + "fields.dialog.repeater.errors.maxItems": { max: number }; + "fields.dialog.repeater.maxItemsHint": { max: number }; + "fields.dialog.repeater.subFieldsHint": { max: number }; "fields.editor.date.daySelected": { date: string | number }; "fields.editor.date.dayToday": { date: string | number }; "fields.editor.fromOriginal": { language: string | number }; @@ -282,11 +297,15 @@ export interface MessageParams { "fields.operation.done": { count: number }; "fields.operation.reverted": { count: number }; "fields.operation.running": { count: number }; + "fields.operation.stuckConfirm": { count: number }; + "fields.operation.stuckTitle": { count: number }; "fields.operation.themeWarning": { key: string | number }; "fields.operation.titles.change_field_type": { name: string | number }; "fields.operation.titles.remove_field": { name: string | number }; "fields.operation.titles.rename_field": { name: string | number }; "fields.operation.titles.set_field_localized": { name: string | number }; + "fields.publish.issue": { language: string | number; field: string | number; reason: "missing" | "hidden" | "locale" }; + "fields.publish.title": { count: number }; "fields.reference.actions": { title: string | number }; "fields.reference.full": { max: number }; "fields.reference.meta": { collection: string | number; language: string | number }; diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index 9f29b75e..c771f7cd 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5cbc390fb4a824fe +// hash: 855e67c1602e1a37 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -711,6 +711,20 @@ export const messages: Messages = { list_field_in_use: "קטעי רשימת תוכן עדיין מסננים או ממיינים לפי השדה הזה. שנו קודם את העמודים האלה.", gates_unsupported: "השרת החי של האתר הזה עדיין לא תומך בזה. פרסו קודם את האתר, ואז נסו שוב.", + entry_referenced: + "רשומות מפורסמות עדיין מציגות את הרשומה הזו בשדה הפניה. בטלו קודם את הפרסום שלהן או שנו אותן.", + reference_target_moved: + "רשומות מפורסמות מציגות את הרשומה הזו מהאוסף הנוכחי שלה. בטלו קודם את הפרסום שלהן או שנו אותן.", + field_operation_running: "שינוי שדה ממיר עכשיו ערכים להפניות לאוסף הזה. המתינו עד שיסתיים.", + unknown_entry: "אי אפשר להשתמש ברשומה שנבחרה: היא נמחקה, הועברה לארכיון או נמצאת באוסף אחר.", + fields_unsupported: "השרת הזה עדיין לא תומך בשדות מסוג הפניה או רשימה.", + translation_group_in_use: + "עדיין יש תרגומים ששייכים לרשומה הזו. העבירו אותם או נתקו את הקישור שלהם לפני שמשנים את הקבוצה שלה.", + too_many_references: "רשומה יכולה להכיל לכל היותר 100 הפניות בכל השדות שלה.", + field_key_in_use: "לרשומות כבר יש ערכים תחת המפתח הזה. בחרו מפתח אחר.", + reference_target_unknown: "שדה הפניה מפנה לאוסף שלא קיים באתר הזה.", + translation_shape_mismatch: + "ברשימה המתורגמת צריך להיות אותו מספר פריטים כמו ברשימה של המקור. שלחו את הרשימה כולה.", network: "אין גישה לשרת. יש לבדוק את החיבור ולנסות שוב.", status: "הבקשה נכשלה (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index 3646aca6..e5f252de 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8604071d7fdc7c3f +// hash: 26f4a0ccfa1d93b1 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -707,6 +707,21 @@ export const messages: Messages = { "सामग्री सूची सेक्शन अब भी इस फ़ील्ड से फ़िल्टर या क्रमबद्ध करते हैं। पहले उन पेजों को बदलें।", gates_unsupported: "इस साइट का लाइव सर्वर अभी इसे सपोर्ट नहीं करता। पहले साइट डिप्लॉय करें, फिर दोबारा कोशिश करें।", + entry_referenced: + "प्रकाशित एंट्री अब भी इस एंट्री को किसी रेफ़रेंस फ़ील्ड में दिखाती हैं। पहले उन्हें अप्रकाशित करें या बदलें।", + reference_target_moved: + "प्रकाशित एंट्री इस एंट्री को उसके मौजूदा संग्रह से दिखाती हैं। पहले उन्हें अप्रकाशित करें या बदलें।", + field_operation_running: + "एक फ़ील्ड बदलाव मानों को इस संग्रह के रेफ़रेंस में बदल रहा है। उसके पूरा होने तक रुकें।", + unknown_entry: + "चुनी गई एक एंट्री इस्तेमाल नहीं की जा सकती: वह हटा दी गई है, आर्काइव की गई है या किसी दूसरे संग्रह में है।", + fields_unsupported: "यह सर्वर अभी रेफ़रेंस या सूची फ़ील्ड स्वीकार नहीं करता।", + translation_group_in_use: + "अनुवाद अब भी इस एंट्री से जुड़े हैं। इसका समूह बदलने से पहले उन्हें कहीं और ले जाएँ या अलग करें।", + too_many_references: "एक एंट्री अपने सभी फ़ील्ड में मिलाकर अधिकतम 100 रेफ़रेंस रख सकती है।", + field_key_in_use: "एंट्री में इस कुंजी के तहत पहले से मान हैं। कोई दूसरी कुंजी चुनें।", + reference_target_unknown: "एक रेफ़रेंस फ़ील्ड ऐसे संग्रह का नाम लेता है जो इस साइट में नहीं है।", + translation_shape_mismatch: "अनुवादित सूची में मूल जितने ही आइटम होने चाहिए। पूरी सूची भेजें।", network: "सर्वर तक नहीं पहुँच पा रहे। अपना कनेक्शन जाँचें और फिर से कोशिश करें।", status: "अनुरोध विफल रहा (HTTP {status})।", }, diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index 8a114b39..04430d7b 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 55b0c339c99af085 +// hash: 2eaaed2265b9d0f1 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -733,6 +733,23 @@ export const messages: Messages = { "Bagian daftar konten masih memfilter atau mengurutkan berdasarkan kolom ini. Ubah halaman-halaman itu terlebih dahulu.", gates_unsupported: "Server live situs ini belum mendukung ini. Deploy situs terlebih dahulu, lalu coba lagi.", + entry_referenced: + "Entri terbit masih menampilkan entri ini di kolom referensi. Batalkan penerbitan atau ubah entri tersebut dulu.", + reference_target_moved: + "Entri terbit menampilkan entri ini dari koleksinya saat ini. Batalkan penerbitan atau ubah entri tersebut dulu.", + field_operation_running: + "Perubahan kolom sedang mengubah nilai menjadi referensi ke koleksi ini. Tunggu sampai selesai.", + unknown_entry: + "Entri yang dipilih tidak bisa dipakai: entri itu dihapus, diarsipkan, atau ada di koleksi lain.", + fields_unsupported: "Server ini belum menerima kolom bertipe Referensi atau Daftar.", + translation_group_in_use: + "Terjemahan masih terhubung ke entri ini. Pindahkan atau lepaskan terjemahan itu sebelum mengubah grupnya.", + too_many_references: "Sebuah entri bisa memuat maksimal 100 referensi di seluruh kolomnya.", + field_key_in_use: "Entri sudah menyimpan nilai dengan kunci ini. Pilih kunci lain.", + reference_target_unknown: + "Sebuah kolom referensi menunjuk koleksi yang tidak ada di situs ini.", + translation_shape_mismatch: + "Daftar terjemahan harus punya item sebanyak daftar versi asli. Kirim seluruh daftar.", network: "Server tidak dapat dijangkau. Periksa koneksi Anda dan coba lagi.", status: "Permintaan gagal (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index e3a6ced4..8758913f 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4d5f1a6f6148b105 +// hash: 6cc38dd0d8a19b2e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -734,6 +734,24 @@ export const messages: Messages = { "Alcune sezioni elenco di contenuti filtrano o ordinano ancora in base a questo campo. Modifica prima quelle pagine.", gates_unsupported: "Il server live di questo sito non lo supporta ancora. Distribuisci prima il sito, poi riprova.", + entry_referenced: + "Alcune voci pubblicate mostrano ancora questa voce in un campo di riferimento. Prima annullane la pubblicazione o modificale.", + reference_target_moved: + "Alcune voci pubblicate mostrano questa voce dalla sua raccolta attuale. Prima annullane la pubblicazione o modificale.", + field_operation_running: + "Una modifica di campo sta trasformando dei valori in riferimenti a questa raccolta. Attendi che termini.", + unknown_entry: + "Una voce scelta non si può usare: è stata eliminata, archiviata o si trova in un’altra raccolta.", + fields_unsupported: "Questo server non accetta ancora campi di tipo Riferimento o Elenco.", + translation_group_in_use: + "Alcune traduzioni appartengono ancora a questa voce. Spostale o scollegale prima di cambiarne il gruppo.", + too_many_references: + "Una voce può contenere al massimo 100 riferimenti in tutti i suoi campi.", + field_key_in_use: "Alcune voci hanno già valori con questa chiave. Scegli un’altra chiave.", + reference_target_unknown: + "Un campo di riferimento indica una raccolta che questo sito non ha.", + translation_shape_mismatch: + "L’elenco tradotto deve avere tanti elementi quanti quello dell’originale. Invia l’intero elenco.", network: "Impossibile raggiungere il server. Controlla la connessione e riprova.", status: "La richiesta non è riuscita (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index 175843e7..2957a737 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: ab88e7a796f77f4f +// hash: 98f75e0a7093a18c import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -730,6 +730,23 @@ export const messages: Messages = { "このフィールドでまだ絞り込みや並べ替えをしているコンテンツリストセクションがあります。先にそれらのページを変更してください。", gates_unsupported: "このサイトのライブサーバーはまだこれに対応していません。先にサイトをデプロイしてから、もう一度お試しください。", + entry_referenced: + "公開済みのエントリーが、参照フィールドでまだこのエントリーを表示しています。先にそれらを公開停止するか変更してください。", + reference_target_moved: + "公開済みのエントリーが、現在のコレクションからこのエントリーを表示しています。先にそれらを公開停止するか変更してください。", + field_operation_running: + "フィールドの変更で、値をこのコレクションへの参照に変換しています。完了するまでお待ちください。", + unknown_entry: + "選択したエントリーを使用できません。削除またはアーカイブされたか、別のコレクションにあります。", + fields_unsupported: "このサーバーは参照型とリスト型のフィールドにまだ対応していません。", + translation_group_in_use: + "このエントリーにはまだ翻訳が属しています。グループを変更する前に、翻訳を移動するかリンクを解除してください。", + too_many_references: + "1つのエントリーが持てる参照は、すべてのフィールドを合わせて100件までです。", + field_key_in_use: "このキーの値を持つエントリーがすでにあります。別のキーを選んでください。", + reference_target_unknown: "参照フィールドが、このサイトにないコレクションを指定しています。", + translation_shape_mismatch: + "翻訳したリストには、オリジナルと同じ数の項目が必要です。リスト全体を送信してください。", network: "サーバーに接続できません。接続を確認して再試行してください。", status: "リクエストに失敗しました(HTTP {status})。", }, diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index 9e6e4d16..b213ef66 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: db81fb254381a9a4 +// hash: d9c04514ce1efa10 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -711,6 +711,21 @@ export const messages: Messages = { "이 필드로 아직 필터링하거나 정렬하는 콘텐츠 목록 섹션이 있어요. 먼저 그 페이지들을 변경하세요.", gates_unsupported: "이 사이트의 라이브 서버는 아직 이 기능을 지원하지 않아요. 먼저 사이트를 배포한 다음 다시 시도하세요.", + entry_referenced: + "게시된 항목이 아직 참조 필드에 이 항목을 표시하고 있어요. 먼저 그 항목들을 게시 취소하거나 바꾸세요.", + reference_target_moved: + "게시된 항목이 현재 컬렉션에서 이 항목을 표시하고 있어요. 먼저 그 항목들을 게시 취소하거나 바꾸세요.", + field_operation_running: + "필드 변경이 값을 이 컬렉션에 대한 참조로 바꾸는 중이에요. 끝날 때까지 기다리세요.", + unknown_entry: "선택한 항목을 사용할 수 없어요. 삭제 또는 보관되었거나 다른 컬렉션에 있어요.", + fields_unsupported: "이 서버는 아직 참조 유형이나 목록 유형 필드를 받지 않아요.", + translation_group_in_use: + "아직 이 항목에 속한 번역이 있어요. 그룹을 바꾸기 전에 번역을 옮기거나 연결을 해제하세요.", + too_many_references: "항목 하나는 모든 필드를 합쳐 참조를 최대 100개까지 담을 수 있어요.", + field_key_in_use: "이미 이 키로 값을 가진 항목이 있어요. 다른 키를 고르세요.", + reference_target_unknown: "참조 필드가 이 사이트에 없는 컬렉션을 가리켜요.", + translation_shape_mismatch: + "번역한 목록은 원본과 아이템 수가 같아야 해요. 목록 전체를 보내세요.", network: "서버에 연결할 수 없어요. 연결을 확인하고 다시 시도하세요.", status: "요청이 실패했어요(HTTP {status}).", }, diff --git a/apps/studio/src/i18n/lazy/fields.ar.ts b/apps/studio/src/i18n/lazy/fields.ar.ts index b01f3519..2a3f6455 100644 --- a/apps/studio/src/i18n/lazy/fields.ar.ts +++ b/apps/studio/src/i18n/lazy/fields.ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 0e2f4d725aa77b38 +// hash: f49d6c245b3558b2 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "رابط", image: "صورة", select: "اختيار", + reference: "مرجع", + repeater: "قائمة", }, editor: { title: "الحقول", @@ -121,6 +123,33 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {أضف} zero {أضف} one {أضف إدخالًا واحدًا} two {أضف إدخالين} few {أضف # إدخالات} many {أضف # إدخالًا} other {أضف # إدخال}}", }, }, + publish: { + title: + "{count, plural, zero {لن يعرض أي مرجع شيئًا} one {مرجع واحد لن يعرض شيئًا} two {مرجعان لن يعرضا شيئًا} few {# مراجع لن تعرض شيئًا} many {# مرجعًا لن تعرض شيئًا} other {# مرجع لن يعرض شيئًا}}", + description: "يستمر النشر. تشير هذه الإدخالات المنشورة إلى إدخال لن يعرضه الموقع.", + issue: + "{language} · {field}: {reason, select, missing {الإدخال المختار لم يعد موجودًا} hidden {الإدخال المختار غير منشور} locale {لا توجد نسخة بهذه اللغة} other {لا يظهر}}", + }, + delete: { + blockedTitle: + "{count, plural, zero {ألغِ نشر الإدخالات أو عدّلها أولًا} one {ألغِ نشر هذا الإدخال أو عدّله أولًا} two {ألغِ نشر هذين الإدخالين أو عدّلهما أولًا} few {ألغِ نشر هذه الإدخالات (#) أو عدّلها أولًا} many {ألغِ نشر هذه الإدخالات (#) أو عدّلها أولًا} other {ألغِ نشر هذه الإدخالات (#) أو عدّلها أولًا}}", + blocked: + "تعرض الصفحات المنشورة هذا الإدخال عبر حقول المرجع فيها، وهو آخر إدخال يمكنها عرضه. لا تفيد أرشفته: فهي لا تزال تشير إليه. غيّرها أو ألغِ نشرها، ثم احذفه.", + moreReferences: + "{count, plural, zero {ولا مراجع أخرى.} one {ومرجع واحد آخر.} two {ومرجعان آخران.} few {و# مراجع أخرى.} many {و# مرجعًا آخر.} other {و# مرجع آخر.}}", + shownByPublished: "تعرضه نسخته المنشورة في: {languages}", + draftsTitle: + "{count, plural, zero {لا توجد مسودات تشير إلى هذا الإدخال} one {مسودة واحدة تشير إلى هذا الإدخال} two {مسودتان تشيران إلى هذا الإدخال} few {# مسودات تشير إلى هذا الإدخال} many {# مسودةً تشير إلى هذا الإدخال} other {# مسودة تشير إلى هذا الإدخال}}", + drafts: + "لم تبقَ نسخة أخرى من هذا الإدخال بلغة أخرى، لذا لن يعرض حقلها شيئًا حتى يُختار إدخال آخر.", + showsNothing: "لن يعرض شيئًا في هذا الحقل", + localeTitle: "ستفقد الصفحات باللغة {language} هذه البطاقة", + locale: "الإدخالات باللغة {language} التي تعرض هذا الإدخال في حقل مرجع لن تعرض شيئًا هناك.", + localeFallback: + "الإدخالات باللغة {language} التي تعرض هذا الإدخال في حقل مرجع ستعرض بدلًا منه النسخة باللغة {fallback}.", + where: "{collection} · {field}", + caption: "الإدخالات التي تعرض هذا الإدخال", + }, usedBy: { title: "مستخدَم في", description: "الإدخالات التي تشير حقولها إلى هذا الإدخال، والمنشورة أولًا.", @@ -209,6 +238,55 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "يمكن للإدخالات اختيار أكثر من خيار.", add: "أضف الحقل", apply: "طبّق", + reference: { + collections: "المجموعات المستهدفة", + collectionsHint: + "يمكن اختيار إدخالات من هذه المجموعات، حتى {max, plural, zero {# مجموعة} one {مجموعة واحدة} two {مجموعتين} few {# مجموعات} many {# مجموعةً} other {# مجموعة}}.", + multiple: "متعدد", + multipleHint: "يمكن للإدخالات اختيار أكثر من إدخال واحد، بترتيب خاص بها.", + maxItems: "الحد الأقصى", + maxItemsHint: "عدد ما يمكن اختياره، حتى {max, number}. فارغ: 20.", + where: "فقط الإدخالات التي", + whereHint: "شروط يستوفيها الإدخال المختار حديثًا. تحتفظ الإدخالات المختارة سابقًا بمكانها.", + whereOneCollection: "لا تُطبَّق الشروط إلا مع مجموعة مستهدفة واحدة.", + whereNoListFields: + "لا تحتوي المجموعة {collection} على حقول قوائم لوضع شروط عليها. أضفها من إعداداتها.", + addCondition: "أضف شرطًا", + errors: { + collections: { + required: "اختر مجموعة واحدة على الأقل.", + limit: "اختر خمس مجموعات على الأكثر.", + }, + maxItems: "أدخل عددًا صحيحًا من 1 إلى {max, number}.", + where: "يستخدم أحد الشروط حقلًا ليس من حقول القوائم في المجموعة المستهدفة.", + }, + }, + repeater: { + subFields: "حقول كل عنصر", + subFieldsHint: + "لكل عنصر هذه الحقول، بهذا الترتيب، حتى {max, plural, zero {# حقل} one {حقل واحد} two {حقلين} few {# حقول} many {# حقلًا} other {# حقل}}. لا يمكن أن تحتوي القائمة على قائمة أخرى.", + add: "أضف حقل عنصر", + addTitle: "أضف حقل عنصر", + editTitle: "عدّل حقل العنصر", + dialogDescription: "تظهر التسميات ونص المساعدة على كل عنصر في المحرر.", + addApply: "أضف", + removeSaved: "أزله عبر قائمة الحقل", + maxItems: "الحد الأقصى", + maxItemsHint: "عدد العناصر التي يمكن أن يحتويها الإدخال، حتى {max, number}. فارغ: 20.", + itemLabel: "اسم العنصر", + itemLabelHint: "الحقل الذي يسمّي كل عنصر في المحرر وفي الموافقات.", + itemLabelFirst: "أول حقل نص أو اختيار أو مرجع", + errors: { + subFields: { + required: "أضف حقلًا واحدًا على الأقل للعناصر.", + limit: "تحتوي القائمة على عشرة حقول على الأكثر لكل عنصر.", + invalid: "أحد حقول العنصر يحتاج إلى إصلاح.", + }, + sub: "يحتاج إلى تسمية ومفتاح غير مستخدم، وإلى مجموعة إذا كان مرجعًا.", + maxItems: "أدخل عددًا صحيحًا من 1 إلى {max, number}.", + itemLabel: "اختر حقل نص أو اختيار أو مرجع.", + }, + }, errors: { label: "امنح الحقل تسمية.", maxLength: "أدخل عددًا صحيحًا من 1 إلى {max, number}.", @@ -245,6 +323,17 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "ستحصل كل ترجمة على قيمتها الخاصة. وإلى أن تُضبط، تعرض قيمة الأصل.", newKey: "المفتاح الجديد", newType: "النوع الجديد", + appliesTo: "ينطبق على", + wholeField: "الحقل بأكمله", + stuckTitle: + "{count, plural, zero {لا توجد إدخالات غير قابلة للتحويل} one {إدخال واحد لم يعد قابلًا للتحويل} two {إدخالان لم يعودا قابلين للتحويل} few {# إدخالات لم تعد قابلة للتحويل} many {# إدخالًا لم يعد قابلًا للتحويل} other {# إدخال لم يعد قابلًا للتحويل}}", + stuckRun: + "تغيّرت قيمها أثناء تغيير الحقل: أُرشف إدخال مختار أو حُذف، أو صار عنوان يطابق إدخالين. لا يمكن أن يكتمل التغيير ما دامت تحتفظ بهذه القيم.", + stuckRevert: + "عُدّلت قيمها بعد التغيير ولا يمكن تحويلها مرة أخرى. لا يمكن أن يكتمل التراجع ما دامت تحتفظ بهذه القيم.", + stuckConfirm: + "امسح {count, plural, zero {هذه القيم} one {هذه القيمة} two {هاتين القيمتين} few {هذه القيم (#)} many {هذه القيم (#)} other {هذه القيم (#)}} لإكمال العملية. تبقى محفوظة في اللقطة الملتقطة قبل التغيير.", + stuckFinish: "امسح وأكمل", check: "راجع ما سيتغير", checking: "يجري فحص الإدخالات…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.de.ts b/apps/studio/src/i18n/lazy/fields.de.ts index bffe26a1..ddea15f8 100644 --- a/apps/studio/src/i18n/lazy/fields.de.ts +++ b/apps/studio/src/i18n/lazy/fields.de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 86531c99468b9a06 +// hash: 3d4c59d715295199 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Link", image: "Bild", select: "Auswahl", + reference: "Verweis", + repeater: "Liste", }, editor: { title: "Felder", @@ -123,6 +125,34 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Hinzufügen} one {# Eintrag hinzufügen} other {# Einträge hinzufügen}}", }, }, + publish: { + title: "{count, plural, one {# Verweis zeigt nichts an} other {# Verweise zeigen nichts an}}", + description: + "Die Veröffentlichung läuft trotzdem. Diese veröffentlichten Einträge nennen einen Eintrag, den die Website nicht anzeigt.", + issue: + "{language} · {field}: {reason, select, missing {der gewählte Eintrag ist nicht mehr da} hidden {der gewählte Eintrag ist nicht veröffentlicht} locale {keine Version in dieser Sprache} other {wird nicht angezeigt}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Heb zuerst die Veröffentlichung dieses Eintrags auf oder bearbeite ihn} other {Heb zuerst die Veröffentlichung dieser # Einträge auf oder bearbeite sie}}", + blocked: + "Veröffentlichte Seiten zeigen diesen Eintrag über ihre Verweisfelder, und er ist der letzte Eintrag, den sie anzeigen können. Archivieren hilft nicht: Sie nennen ihn weiterhin. Ändere sie oder heb ihre Veröffentlichung auf und lösche ihn dann.", + moreReferences: + "{count, plural, one {Und # weiterer Verweis.} other {Und # weitere Verweise.}}", + shownByPublished: "Angezeigt durch die veröffentlichte Version in {languages}", + draftsTitle: + "{count, plural, one {# Entwurf nennt diesen Eintrag} other {# Entwürfe nennen diesen Eintrag}}", + drafts: + "Von diesem Eintrag bleibt keine andere Sprachversion übrig, daher zeigt ihr Feld nichts an, bis sie einen anderen wählen.", + showsNothing: "Zeigt für dieses Feld nichts an", + localeTitle: "Seiten in {language} verlieren diese Karte", + locale: + "Einträge in {language}, die diesen Eintrag in einem Verweisfeld zeigen, zeigen dort nichts mehr an.", + localeFallback: + "Einträge in {language}, die diesen Eintrag in einem Verweisfeld zeigen, zeigen stattdessen die Version in {fallback}.", + where: "{collection} · {field}", + caption: "Einträge, die diesen Eintrag zeigen", + }, usedBy: { title: "Verwendet von", description: "Einträge, deren Felder auf diesen verweisen, veröffentlichte zuerst.", @@ -217,6 +247,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Einträge können mehr als eine Option wählen.", add: "Feld hinzufügen", apply: "Übernehmen", + reference: { + collections: "Zielsammlungen", + collectionsHint: + "Einträge dieser Sammlungen können gewählt werden, bis zu {max, plural, one {# Sammlung} other {# Sammlungen}}.", + multiple: "Mehrere", + multipleHint: "Einträge können mehr als einen wählen, in eigener Reihenfolge.", + maxItems: "Höchstens", + maxItemsHint: "Wie viele gewählt werden können, bis zu {max, number}. Leer: 20.", + where: "Nur Einträge, bei denen", + whereHint: + "Bedingungen, die ein neu gewählter Eintrag erfüllt. Bereits gewählte Einträge behalten ihren Platz.", + whereOneCollection: "Bedingungen gibt es nur mit einer einzigen Zielsammlung.", + whereNoListFields: + "{collection} hat keine Listenfelder, für die sich Bedingungen setzen lassen. Füge sie in den Einstellungen der Sammlung hinzu.", + addCondition: "Bedingung hinzufügen", + errors: { + collections: { + required: "Wähl mindestens eine Sammlung.", + limit: "Wähl höchstens fünf Sammlungen.", + }, + maxItems: "Gib eine ganze Zahl von 1 bis {max, number} ein.", + where: "Eine Bedingung nutzt ein Feld, das kein Listenfeld der Zielsammlung ist.", + }, + }, + repeater: { + subFields: "Felder jedes Elements", + subFieldsHint: + "Jedes Element hat diese Felder, in dieser Reihenfolge, bis zu {max, plural, one {# Feld} other {# Felder}}. Eine Liste kann keine andere Liste enthalten.", + add: "Elementfeld hinzufügen", + addTitle: "Elementfeld hinzufügen", + editTitle: "Elementfeld bearbeiten", + dialogDescription: "Beschriftungen und Hilfetexte erscheinen im Editor bei jedem Element.", + addApply: "Hinzufügen", + removeSaved: "Über das Menü des Felds entfernen", + maxItems: "Höchstens", + maxItemsHint: "Wie viele Elemente ein Eintrag haben kann, bis zu {max, number}. Leer: 20.", + itemLabel: "Elementname", + itemLabelHint: "Das Feld, das jedes Element im Editor und in Freigaben benennt.", + itemLabelFirst: "Das erste Text-, Auswahl- oder Verweisfeld", + errors: { + subFields: { + required: "Füge mindestens ein Feld für die Elemente hinzu.", + limit: "Eine Liste hat höchstens zehn Felder pro Element.", + invalid: "Eines der Elementfelder muss korrigiert werden.", + }, + sub: "Braucht eine Beschriftung, einen freien Schlüssel und bei einem Verweis eine Sammlung.", + maxItems: "Gib eine ganze Zahl von 1 bis {max, number} ein.", + itemLabel: "Wähl ein Text-, Auswahl- oder Verweisfeld.", + }, + }, errors: { label: "Gib dem Feld eine Beschriftung.", maxLength: "Gib eine ganze Zahl von 1 bis {max, number} ein.", @@ -257,6 +337,17 @@ export const messages: NamespaceMessages<"fields"> = { "Jede Übersetzung bekommt einen eigenen Wert. Bis einer gesetzt ist, zeigt sie den des Originals.", newKey: "Neuer Schlüssel", newType: "Neuer Typ", + appliesTo: "Gilt für", + wholeField: "Das ganze Feld", + stuckTitle: + "{count, plural, one {# Eintrag lässt sich nicht mehr umwandeln} other {# Einträge lassen sich nicht mehr umwandeln}}", + stuckRun: + "Ihre Werte haben sich geändert, während das Feld geändert wurde: Ein gewählter Eintrag wurde archiviert oder gelöscht, oder ein Titel passt jetzt auf zwei Einträge. Die Änderung kann nicht abgeschlossen werden, solange sie diese Werte behalten.", + stuckRevert: + "Ihre Werte wurden nach der Änderung bearbeitet und lassen sich nicht zurückwandeln. Das Rückgängigmachen kann nicht abgeschlossen werden, solange sie diese Werte behalten.", + stuckConfirm: + "Leere {count, plural, one {diesen Wert} other {diese # Werte}}, um abzuschließen. Sie bleiben im Snapshot erhalten, der vor der Änderung erstellt wurde.", + stuckFinish: "Leeren und abschließen", check: "Prüfen, was sich ändert", checking: "Einträge werden geprüft…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.en.ts b/apps/studio/src/i18n/lazy/fields.en.ts index d1d3516c..ec511a0a 100644 --- a/apps/studio/src/i18n/lazy/fields.en.ts +++ b/apps/studio/src/i18n/lazy/fields.en.ts @@ -11,6 +11,8 @@ export const messages = { url: "Link", image: "Image", select: "Choice", + reference: "Reference", + repeater: "List", }, editor: { title: "Fields", @@ -116,6 +118,34 @@ export const messages = { add: "{count, plural, =0 {Add} one {Add # entry} other {Add # entries}}", }, }, + publish: { + title: + "{count, plural, one {# reference will show nothing} other {# references will show nothing}}", + description: + "Publishing goes ahead. These published entries name an entry the site won't show.", + issue: + "{language} · {field}: {reason, select, missing {the chosen entry is gone} hidden {the chosen entry isn't published} locale {no version in this language} other {not shown}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Unpublish or edit this entry first} other {Unpublish or edit these # entries first}}", + blocked: + "Published pages show this entry through their reference fields, and it is the last entry they can show. Archiving it doesn't help: they still name it. Change or unpublish them, then delete.", + moreReferences: "{count, plural, one {And # more reference.} other {And # more references.}}", + shownByPublished: "Shown by its published version in {languages}", + draftsTitle: + "{count, plural, one {# draft names this entry} other {# drafts name this entry}}", + drafts: + "No other entry of this group is left, so their field shows nothing until they choose another.", + showsNothing: "Will show nothing for this field", + localeTitle: "{language} pages lose this card", + locale: + "Entries in {language} that show this entry in a reference field will show nothing there.", + localeFallback: + "Entries in {language} that show this entry in a reference field will show the {fallback} version instead.", + where: "{collection} · {field}", + caption: "Entries that show this entry", + }, usedBy: { title: "Used by", description: "Entries whose fields reference this one, published first.", @@ -207,6 +237,55 @@ export const messages = { multipleHint: "Entries can pick more than one option.", add: "Add field", apply: "Apply", + reference: { + collections: "Target collections", + collectionsHint: + "Entries of these collections can be chosen, up to {max, plural, one {# collection} other {# collections}}.", + multiple: "Several", + multipleHint: "Entries can choose more than one, in an order of their own.", + maxItems: "At most", + maxItemsHint: "How many can be chosen, up to {max, number}. Empty: 20.", + where: "Only entries where", + whereHint: "Conditions a newly chosen entry meets. Entries chosen before keep their place.", + whereOneCollection: "Conditions apply with one target collection.", + whereNoListFields: + "{collection} has no listing fields to set conditions on. Add them in its settings.", + addCondition: "Add condition", + errors: { + collections: { + required: "Choose at least one collection.", + limit: "Choose at most five collections.", + }, + maxItems: "Enter a whole number from 1 to {max, number}.", + where: "A condition uses a field that isn't a listing field of the target collection.", + }, + }, + repeater: { + subFields: "Fields of each item", + subFieldsHint: + "Every item has these, in this order, up to {max, plural, one {# field} other {# fields}}. A list can't hold another list.", + add: "Add item field", + addTitle: "Add item field", + editTitle: "Edit item field", + dialogDescription: "Labels and help show on every item in the editor.", + addApply: "Add", + removeSaved: "Remove through the field's menu", + maxItems: "At most", + maxItemsHint: "How many items an entry can have, up to {max, number}. Empty: 20.", + itemLabel: "Item name", + itemLabelHint: "The field that names each item in the editor and in approvals.", + itemLabelFirst: "The first text, choice or reference", + errors: { + subFields: { + required: "Add at least one field for the items.", + limit: "A list holds at most ten fields per item.", + invalid: "One of the item fields needs a fix.", + }, + sub: "Needs a label, a free key, and for a reference a collection.", + maxItems: "Enter a whole number from 1 to {max, number}.", + itemLabel: "Choose a text, choice or reference field.", + }, + }, errors: { label: "Give the field a label.", maxLength: "Enter a whole number from 1 to {max, number}.", @@ -246,6 +325,17 @@ export const messages = { "Every translation gets a value of its own. Until one is set, it shows the original's.", newKey: "New key", newType: "New type", + appliesTo: "Applies to", + wholeField: "The whole field", + stuckTitle: + "{count, plural, one {# entry no longer converts} other {# entries no longer convert}}", + stuckRun: + "Their values changed while the field was being changed: a chosen entry was archived or deleted, or a title now matches two entries. The change can't finish while they keep these values.", + stuckRevert: + "Their values were edited after the change and can't be converted back. The revert can't finish while they keep these values.", + stuckConfirm: + "Clear {count, plural, one {this value} other {these # values}} to finish. They are kept in the snapshot taken before the change.", + stuckFinish: "Clear and finish", check: "Check what changes", checking: "Checking entries…", clear: "Clear {count, plural, one {# value} other {# values}} that can't be converted", diff --git a/apps/studio/src/i18n/lazy/fields.es.ts b/apps/studio/src/i18n/lazy/fields.es.ts index e8f28b7d..0a4cf6c0 100644 --- a/apps/studio/src/i18n/lazy/fields.es.ts +++ b/apps/studio/src/i18n/lazy/fields.es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: df243de4e4300176 +// hash: 490415a8b560a4a6 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Enlace", image: "Imagen", select: "Elección", + reference: "Referencia", + repeater: "Lista", }, editor: { title: "Campos", @@ -123,6 +125,35 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Añadir} one {Añadir # entrada} many {Añadir # de entradas} other {Añadir # entradas}}", }, }, + publish: { + title: + "{count, plural, one {# referencia no mostrará nada} many {# de referencias no mostrarán nada} other {# referencias no mostrarán nada}}", + description: + "La publicación sigue adelante. Estas entradas publicadas nombran una entrada que el sitio no mostrará.", + issue: + "{language} · {field}: {reason, select, missing {la entrada elegida ya no existe} hidden {la entrada elegida no está publicada} locale {no hay versión en este idioma} other {no se muestra}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Primero despublica o edita esta entrada} many {Primero despublica o edita estas # de entradas} other {Primero despublica o edita estas # entradas}}", + blocked: + "Hay páginas publicadas que muestran esta entrada en sus campos de referencia, y es la última entrada que pueden mostrar. Archivarla no sirve: siguen nombrándola. Cámbialas o despublícalas y después elimínala.", + moreReferences: + "{count, plural, one {Y # referencia más.} many {Y # de referencias más.} other {Y # referencias más.}}", + shownByPublished: "Mostrada por su versión publicada en {languages}", + draftsTitle: + "{count, plural, one {# borrador nombra esta entrada} many {# de borradores nombran esta entrada} other {# borradores nombran esta entrada}}", + drafts: + "No queda ninguna otra versión de idioma de esta entrada, así que su campo no mostrará nada hasta que elijan otra.", + showsNothing: "No mostrará nada en este campo", + localeTitle: "Las páginas en {language} pierden esta tarjeta", + locale: + "Las entradas en {language} que muestran esta entrada en un campo de referencia no mostrarán nada ahí.", + localeFallback: + "Las entradas en {language} que muestran esta entrada en un campo de referencia mostrarán en su lugar la versión en {fallback}.", + where: "{collection} · {field}", + caption: "Entradas que muestran esta entrada", + }, usedBy: { title: "Usada por", description: "Entradas cuyos campos hacen referencia a esta, primero las publicadas.", @@ -217,6 +248,57 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Las entradas pueden elegir más de una opción.", add: "Añadir campo", apply: "Aplicar", + reference: { + collections: "Colecciones de destino", + collectionsHint: + "Se pueden elegir entradas de estas colecciones, hasta {max, plural, one {# colección} many {# de colecciones} other {# colecciones}}.", + multiple: "Varias", + multipleHint: "Las entradas pueden elegir más de una, en su propio orden.", + maxItems: "Como máximo", + maxItemsHint: "Cuántas se pueden elegir, hasta {max, number}. Vacío: 20.", + where: "Solo entradas en las que", + whereHint: + "Condiciones que cumple una entrada recién elegida. Las entradas elegidas antes conservan su lugar.", + whereOneCollection: "Las condiciones solo se aplican con una única colección de destino.", + whereNoListFields: + "{collection} no tiene campos de listado sobre los que poner condiciones. Añádelos en sus ajustes.", + addCondition: "Añadir condición", + errors: { + collections: { + required: "Elige al menos una colección.", + limit: "Elige cinco colecciones como máximo.", + }, + maxItems: "Introduce un número entero del 1 al {max, number}.", + where: + "Una condición usa un campo que no es un campo de listado de la colección de destino.", + }, + }, + repeater: { + subFields: "Campos de cada elemento", + subFieldsHint: + "Cada elemento los tiene, en este orden, hasta {max, plural, one {# campo} many {# de campos} other {# campos}}. Una lista no puede contener otra lista.", + add: "Añadir campo de elemento", + addTitle: "Añadir campo de elemento", + editTitle: "Editar campo de elemento", + dialogDescription: "Las etiquetas y la ayuda se muestran en cada elemento del editor.", + addApply: "Añadir", + removeSaved: "Quítalo desde el menú del campo", + maxItems: "Como máximo", + maxItemsHint: "Cuántos elementos puede tener una entrada, hasta {max, number}. Vacío: 20.", + itemLabel: "Nombre del elemento", + itemLabelHint: "El campo que nombra cada elemento en el editor y en las aprobaciones.", + itemLabelFirst: "El primer campo de texto, elección o referencia", + errors: { + subFields: { + required: "Añade al menos un campo para los elementos.", + limit: "Una lista admite como máximo diez campos por elemento.", + invalid: "Uno de los campos de elemento necesita una corrección.", + }, + sub: "Necesita una etiqueta, una clave libre y, si es una referencia, una colección.", + maxItems: "Introduce un número entero del 1 al {max, number}.", + itemLabel: "Elige un campo de texto, elección o referencia.", + }, + }, errors: { label: "Ponle una etiqueta al campo.", maxLength: "Escribe un número entero de 1 a {max, number}.", @@ -256,6 +338,17 @@ export const messages: NamespaceMessages<"fields"> = { "Cada traducción tendrá su propio valor. Hasta que se ponga uno, muestra el del original.", newKey: "Nueva clave", newType: "Nuevo tipo", + appliesTo: "Se aplica a", + wholeField: "Todo el campo", + stuckTitle: + "{count, plural, one {# entrada ya no se puede convertir} many {# de entradas ya no se pueden convertir} other {# entradas ya no se pueden convertir}}", + stuckRun: + "Sus valores cambiaron mientras se cambiaba el campo: una entrada elegida se archivó o se eliminó, o un título ahora coincide con dos entradas. El cambio no puede terminar mientras conserven estos valores.", + stuckRevert: + "Sus valores se editaron después del cambio y no se pueden volver a convertir. La reversión no puede terminar mientras conserven estos valores.", + stuckConfirm: + "Vacía {count, plural, one {este valor} many {estos # de valores} other {estos # valores}} para terminar. Se conservan en el snapshot tomado antes del cambio.", + stuckFinish: "Vaciar y terminar", check: "Ver qué cambia", checking: "Revisando entradas…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.fa.ts b/apps/studio/src/i18n/lazy/fields.fa.ts index f181041c..49b9bf13 100644 --- a/apps/studio/src/i18n/lazy/fields.fa.ts +++ b/apps/studio/src/i18n/lazy/fields.fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4ac4df22d9112e28 +// hash: 970f99e1127d0977 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "پیوند", image: "تصویر", select: "انتخاب", + reference: "ارجاع", + repeater: "فهرست", }, editor: { title: "فیلدها", @@ -120,6 +122,34 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {افزودن} one {افزودن # ورودی} other {افزودن # ورودی}}", }, }, + publish: { + title: + "{count, plural, one {# ارجاع چیزی نشان نخواهد داد} other {# ارجاع چیزی نشان نخواهند داد}}", + description: + "انتشار انجام می‌شود. این ورودی‌های منتشرشده به ورودی‌ای ارجاع می‌دهند که سایت آن را نشان نمی‌دهد.", + issue: + "{language} · {field}: {reason, select, missing {ورودی انتخاب‌شده دیگر وجود ندارد} hidden {ورودی انتخاب‌شده منتشر نشده است} locale {نسخه‌ای به این زبان نیست} other {نمایش داده نمی‌شود}}", + }, + delete: { + blockedTitle: + "{count, plural, one {ابتدا انتشار این ورودی را لغو یا آن را ویرایش کنید} other {ابتدا انتشار این # ورودی را لغو یا آن‌ها را ویرایش کنید}}", + blocked: + "صفحه‌های منتشرشده این ورودی را از طریق فیلدهای ارجاعشان نشان می‌دهند و این آخرین ورودی‌ای است که می‌توانند نشان دهند. بایگانی کردنش کمکی نمی‌کند: همچنان به آن ارجاع می‌دهند. آن‌ها را تغییر دهید یا انتشارشان را لغو کنید، سپس حذف کنید.", + moreReferences: "{count, plural, one {و # ارجاع دیگر.} other {و # ارجاع دیگر.}}", + shownByPublished: "نسخهٔ منتشرشده‌اش در این زبان‌ها آن را نشان می‌دهد: {languages}", + draftsTitle: + "{count, plural, one {# پیش‌نویس به این ورودی ارجاع می‌دهد} other {# پیش‌نویس به این ورودی ارجاع می‌دهند}}", + drafts: + "نسخهٔ دیگری از این ورودی به زبان دیگر باقی نمانده است، پس فیلدشان تا وقتی ورودی دیگری انتخاب نکنند چیزی نشان نمی‌دهد.", + showsNothing: "برای این فیلد چیزی نشان نمی‌دهد", + localeTitle: "صفحه‌های زبان {language} این کارت را از دست می‌دهند", + locale: + "ورودی‌های زبان {language} که این ورودی را در یک فیلد ارجاع نشان می‌دهند، آنجا چیزی نشان نخواهند داد.", + localeFallback: + "ورودی‌های زبان {language} که این ورودی را در یک فیلد ارجاع نشان می‌دهند، به‌جای آن نسخهٔ زبان {fallback} را نشان خواهند داد.", + where: "{collection} · {field}", + caption: "ورودی‌هایی که این ورودی را نشان می‌دهند", + }, usedBy: { title: "استفاده‌شده در", description: "ورودی‌هایی که فیلدهایشان به این ورودی ارجاع می‌دهند، اول منتشرشده‌ها.", @@ -212,6 +242,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "ورودی‌ها می‌توانند بیش از یک گزینه انتخاب کنند.", add: "افزودن فیلد", apply: "اعمال", + reference: { + collections: "مجموعه‌های مقصد", + collectionsHint: + "ورودی‌های این مجموعه‌ها را می‌توان انتخاب کرد، حداکثر {max, plural, one {# مجموعه} other {# مجموعه}}.", + multiple: "چندتایی", + multipleHint: "ورودی‌ها می‌توانند بیش از یکی انتخاب کنند، با ترتیب دلخواه خودشان.", + maxItems: "حداکثر", + maxItemsHint: "چند مورد را می‌توان انتخاب کرد، تا {max, number}. خالی: 20.", + where: "فقط ورودی‌هایی که", + whereHint: + "شرط‌هایی که ورودیِ تازه انتخاب‌شده دارد. ورودی‌هایی که پیش‌تر انتخاب شده‌اند جایشان را حفظ می‌کنند.", + whereOneCollection: "شرط‌ها فقط با یک مجموعهٔ مقصد اعمال می‌شوند.", + whereNoListFields: + "مجموعهٔ {collection} فیلد فهرستی برای تعیین شرط ندارد. آن‌ها را در تنظیماتش اضافه کنید.", + addCondition: "افزودن شرط", + errors: { + collections: { + required: "دست‌کم یک مجموعه انتخاب کنید.", + limit: "حداکثر پنج مجموعه انتخاب کنید.", + }, + maxItems: "یک عدد صحیح از 1 تا {max, number} وارد کنید.", + where: "یک شرط از فیلدی استفاده می‌کند که فیلد فهرستِ مجموعهٔ مقصد نیست.", + }, + }, + repeater: { + subFields: "فیلدهای هر مورد", + subFieldsHint: + "هر مورد این فیلدها را به همین ترتیب دارد، حداکثر {max, plural, one {# فیلد} other {# فیلد}}. یک فهرست نمی‌تواند فهرست دیگری را در خود داشته باشد.", + add: "افزودن فیلد مورد", + addTitle: "افزودن فیلد مورد", + editTitle: "ویرایش فیلد مورد", + dialogDescription: "برچسب‌ها و راهنما روی هر مورد در ویرایشگر نمایش داده می‌شوند.", + addApply: "افزودن", + removeSaved: "حذف از طریق منوی فیلد", + maxItems: "حداکثر", + maxItemsHint: "تعداد موردهایی که یک ورودی می‌تواند داشته باشد، تا {max, number}. خالی: 20.", + itemLabel: "نام مورد", + itemLabelHint: "فیلدی که هر مورد را در ویرایشگر و در تأییدها نام‌گذاری می‌کند.", + itemLabelFirst: "نخستین فیلد متن، انتخاب یا ارجاع", + errors: { + subFields: { + required: "دست‌کم یک فیلد برای موردها اضافه کنید.", + limit: "هر مورد در یک فهرست حداکثر ده فیلد دارد.", + invalid: "یکی از فیلدهای مورد نیاز به اصلاح دارد.", + }, + sub: "به یک برچسب، یک کلید آزاد و، برای ارجاع، یک مجموعه نیاز دارد.", + maxItems: "یک عدد صحیح از 1 تا {max, number} وارد کنید.", + itemLabel: "یک فیلد متن، انتخاب یا ارجاع انتخاب کنید.", + }, + }, errors: { label: "به فیلد یک برچسب بدهید.", maxLength: "یک عدد صحیح از 1 تا {max, number} وارد کنید.", @@ -251,6 +331,17 @@ export const messages: NamespaceMessages<"fields"> = { "هر ترجمه مقدار خودش را خواهد داشت. تا وقتی مقداری تعیین نشده، مقدار نسخهٔ اصلی را نشان می‌دهد.", newKey: "کلید تازه", newType: "نوع تازه", + appliesTo: "اعمال روی", + wholeField: "کل فیلد", + stuckTitle: + "{count, plural, one {# ورودی دیگر تبدیل نمی‌شود} other {# ورودی دیگر تبدیل نمی‌شوند}}", + stuckRun: + "مقدارهایشان هنگام تغییر فیلد عوض شد: یک ورودی انتخاب‌شده بایگانی یا حذف شد، یا یک عنوان اکنون با دو ورودی جور درمی‌آید. تا وقتی این مقدارها را دارند، تغییر نمی‌تواند تمام شود.", + stuckRevert: + "مقدارهایشان پس از تغییر ویرایش شده‌اند و نمی‌توان آن‌ها را برگرداند. تا وقتی این مقدارها را دارند، بازگردانی نمی‌تواند تمام شود.", + stuckConfirm: + "برای تمام کردن، {count, plural, one {این مقدار} other {این # مقدار}} را پاک کنید. آن‌ها در snapshot گرفته‌شده پیش از تغییر نگه داشته می‌شوند.", + stuckFinish: "پاک کردن و تمام کردن", check: "دیدن تغییرات", checking: "در حال بررسی ورودی‌ها…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.fr.ts b/apps/studio/src/i18n/lazy/fields.fr.ts index 88027c53..ff91d10e 100644 --- a/apps/studio/src/i18n/lazy/fields.fr.ts +++ b/apps/studio/src/i18n/lazy/fields.fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9b9c7d54ed97280d +// hash: c918c611ca3bf41f import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Lien", image: "Image", select: "Choix", + reference: "Référence", + repeater: "Liste", }, editor: { title: "Champs", @@ -124,6 +126,35 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Ajouter} one {Ajouter # entrée} many {Ajouter # d’entrées} other {Ajouter # entrées}}", }, }, + publish: { + title: + "{count, plural, one {# référence n’affichera rien} many {# de références n’afficheront rien} other {# références n’afficheront rien}}", + description: + "La publication a bien lieu. Ces entrées publiées désignent une entrée que le site n’affichera pas.", + issue: + "{language} · {field} : {reason, select, missing {l’entrée choisie n’existe plus} hidden {l’entrée choisie n’est pas publiée} locale {aucune version dans cette langue} other {non affichée}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Dépubliez ou modifiez d’abord cette entrée} many {Dépubliez ou modifiez d’abord ces # d’entrées} other {Dépubliez ou modifiez d’abord ces # entrées}}", + blocked: + "Des pages publiées affichent cette entrée via leurs champs de référence, et c’est la dernière entrée qu’elles peuvent afficher. L’archiver ne suffit pas : elles la désignent toujours. Modifiez-les ou dépubliez-les, puis supprimez-la.", + moreReferences: + "{count, plural, one {Et # autre référence.} many {Et # d’autres références.} other {Et # autres références.}}", + shownByPublished: "Affichée par sa version publiée en {languages}", + draftsTitle: + "{count, plural, one {# brouillon désigne cette entrée} many {# de brouillons désignent cette entrée} other {# brouillons désignent cette entrée}}", + drafts: + "Il ne reste aucune autre version linguistique de cette entrée : leur champ n’affichera rien tant qu’ils n’en auront pas choisi une autre.", + showsNothing: "N’affichera rien pour ce champ", + localeTitle: "Les pages en {language} perdent cette carte", + locale: + "Les entrées en {language} qui affichent cette entrée dans un champ de référence n’y afficheront plus rien.", + localeFallback: + "Les entrées en {language} qui affichent cette entrée dans un champ de référence afficheront à la place la version en {fallback}.", + where: "{collection} · {field}", + caption: "Entrées qui affichent cette entrée", + }, usedBy: { title: "Utilisée par", description: "Les entrées dont les champs font référence à celle-ci, publiées en premier.", @@ -219,6 +250,57 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Les entrées peuvent choisir plusieurs options.", add: "Ajouter le champ", apply: "Appliquer", + reference: { + collections: "Collections cibles", + collectionsHint: + "Les entrées de ces collections peuvent être choisies, jusqu’à {max, plural, one {# collection} many {# de collections} other {# collections}}.", + multiple: "Plusieurs", + multipleHint: "Les entrées peuvent en choisir plusieurs, dans l’ordre de leur choix.", + maxItems: "Au maximum", + maxItemsHint: "Combien peuvent être choisies, jusqu’à {max, number}. Vide : 20.", + where: "Seulement les entrées où", + whereHint: + "Conditions que remplit une entrée nouvellement choisie. Les entrées choisies avant gardent leur place.", + whereOneCollection: "Les conditions ne s’appliquent qu’avec une seule collection cible.", + whereNoListFields: + "{collection} n’a aucun champ de liste sur lequel poser des conditions. Ajoutez-en dans ses paramètres.", + addCondition: "Ajouter une condition", + errors: { + collections: { + required: "Choisissez au moins une collection.", + limit: "Choisissez au plus cinq collections.", + }, + maxItems: "Saisissez un nombre entier de 1 à {max, number}.", + where: + "Une condition utilise un champ qui n’est pas un champ de liste de la collection cible.", + }, + }, + repeater: { + subFields: "Champs de chaque élément", + subFieldsHint: + "Chaque élément les possède, dans cet ordre, jusqu’à {max, plural, one {# champ} many {# de champs} other {# champs}}. Une liste ne peut pas contenir une autre liste.", + add: "Ajouter un champ d’élément", + addTitle: "Ajouter un champ d’élément", + editTitle: "Modifier le champ d’élément", + dialogDescription: "Les libellés et l’aide s’affichent sur chaque élément dans l’éditeur.", + addApply: "Ajouter", + removeSaved: "Retirez-le depuis le menu du champ", + maxItems: "Au maximum", + maxItemsHint: "Combien d’éléments une entrée peut avoir, jusqu’à {max, number}. Vide : 20.", + itemLabel: "Nom de l’élément", + itemLabelHint: "Le champ qui nomme chaque élément dans l’éditeur et dans les approbations.", + itemLabelFirst: "Le premier champ texte, choix ou référence", + errors: { + subFields: { + required: "Ajoutez au moins un champ pour les éléments.", + limit: "Une liste contient au plus dix champs par élément.", + invalid: "Un des champs d’élément doit être corrigé.", + }, + sub: "Il faut un libellé, une clé libre et, pour une référence, une collection.", + maxItems: "Saisissez un nombre entier de 1 à {max, number}.", + itemLabel: "Choisissez un champ texte, choix ou référence.", + }, + }, errors: { label: "Donnez un libellé au champ.", maxLength: "Saisissez un nombre entier de 1 à {max, number}.", @@ -258,6 +340,17 @@ export const messages: NamespaceMessages<"fields"> = { "Chaque traduction aura sa propre valeur. Tant qu’aucune n’est saisie, elle affiche celle de l’original.", newKey: "Nouvelle clé", newType: "Nouveau type", + appliesTo: "S’applique à", + wholeField: "Le champ entier", + stuckTitle: + "{count, plural, one {# entrée ne peut plus être convertie} many {# d’entrées ne peuvent plus être converties} other {# entrées ne peuvent plus être converties}}", + stuckRun: + "Leurs valeurs ont changé pendant la modification du champ : une entrée choisie a été archivée ou supprimée, ou un titre correspond désormais à deux entrées. Le changement ne peut pas aboutir tant qu’elles gardent ces valeurs.", + stuckRevert: + "Leurs valeurs ont été modifiées après le changement et ne peuvent pas être reconverties. L’annulation ne peut pas aboutir tant qu’elles gardent ces valeurs.", + stuckConfirm: + "Effacez {count, plural, one {cette valeur} many {ces # de valeurs} other {ces # valeurs}} pour terminer. Elles sont conservées dans le snapshot pris avant le changement.", + stuckFinish: "Effacer et terminer", check: "Voir ce qui change", checking: "Vérification des entrées…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.he.ts b/apps/studio/src/i18n/lazy/fields.he.ts index 91bb299c..ab2d1d72 100644 --- a/apps/studio/src/i18n/lazy/fields.he.ts +++ b/apps/studio/src/i18n/lazy/fields.he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 57f88a6cab9cd52a +// hash: a932f5a9b393a06f import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "קישור", image: "תמונה", select: "בחירה", + reference: "הפניה", + repeater: "רשימה", }, editor: { title: "שדות", @@ -118,6 +120,33 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {הוספה} one {הוספת רשומה אחת} two {הוספת שתי רשומות} other {הוספת # רשומות}}", }, }, + publish: { + title: + "{count, plural, one {הפניה אחת לא תציג דבר} two {שתי הפניות לא יציגו דבר} other {# הפניות לא יציגו דבר}}", + description: "הפרסום ממשיך. הרשומות המפורסמות האלה מפנות לרשומה שהאתר לא יציג.", + issue: + "{language} · {field}: {reason, select, missing {הרשומה שנבחרה כבר לא קיימת} hidden {הרשומה שנבחרה לא פורסמה} locale {אין גרסה בשפה הזו} other {לא מוצגת}}", + }, + delete: { + blockedTitle: + "{count, plural, one {קודם בטלו את הפרסום של הרשומה הזו או ערכו אותה} two {קודם בטלו את הפרסום של שתי הרשומות האלה או ערכו אותן} other {קודם בטלו את הפרסום של # הרשומות האלה או ערכו אותן}}", + blocked: + "דפים מפורסמים מציגים את הרשומה הזו דרך שדות ההפניה שלהם, והיא הרשומה האחרונה שהם יכולים להציג. העברה לארכיון לא תעזור: הם עדיין מפנים אליה. שנו אותם או בטלו את הפרסום שלהם, ואז מחקו.", + moreReferences: + "{count, plural, one {ועוד הפניה אחת.} two {ועוד שתי הפניות.} other {ועוד # הפניות.}}", + shownByPublished: "מוצגת בגרסה המפורסמת שלה בשפות: {languages}", + draftsTitle: + "{count, plural, one {טיוטה אחת מפנה לרשומה הזו} two {שתי טיוטות מפנות לרשומה הזו} other {# טיוטות מפנות לרשומה הזו}}", + drafts: + "לא נשארה גרסת שפה אחרת של הרשומה הזו, ולכן השדה שלהן לא יציג דבר עד שייבחר בו משהו אחר.", + showsNothing: "לא יוצג דבר בשדה הזה", + localeTitle: "עמודים בשפה {language} יאבדו את הכרטיס הזה", + locale: "רשומות בשפה {language} שמציגות את הרשומה הזו בשדה הפניה לא יציגו שם דבר.", + localeFallback: + "רשומות בשפה {language} שמציגות את הרשומה הזו בשדה הפניה יציגו במקומה את הגרסה בשפה {fallback}.", + where: "{collection} · {field}", + caption: "רשומות שמציגות את הרשומה הזו", + }, usedBy: { title: "בשימוש אצל", description: "רשומות שהשדות שלהן מפנים לרשומה הזו, המפורסמות קודם.", @@ -205,6 +234,55 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "רשומות יכולות לבחור יותר מאפשרות אחת.", add: "הוספת שדה", apply: "החלה", + reference: { + collections: "אוספי יעד", + collectionsHint: + "אפשר לבחור רשומות מהאוספים האלה, עד {max, plural, one {אוסף אחד} two {שני אוספים} other {# אוספים}}.", + multiple: "בחירה מרובה", + multipleHint: "רשומות יכולות לבחור יותר מאחת, בסדר משלהן.", + maxItems: "לכל היותר", + maxItemsHint: "כמה אפשר לבחור, עד {max, number}. ריק: 20.", + where: "רק רשומות שבהן", + whereHint: "תנאים שרשומה חדשה שנבחרת עומדת בהם. רשומות שנבחרו קודם שומרות על מקומן.", + whereOneCollection: "תנאים חלים רק כשיש אוסף יעד אחד.", + whereNoListFields: + "לאוסף {collection} אין שדות רשימה להגדרת תנאים. הוסיפו אותם בהגדרות שלו.", + addCondition: "הוספת תנאי", + errors: { + collections: { + required: "בחרו לפחות אוסף אחד.", + limit: "בחרו לכל היותר חמישה אוספים.", + }, + maxItems: "הזינו מספר שלם מ-1 עד {max, number}.", + where: "אחד התנאים משתמש בשדה שאינו שדה רשימה של אוסף היעד.", + }, + }, + repeater: { + subFields: "השדות של כל פריט", + subFieldsHint: + "לכל פריט יש את השדות האלה, בסדר הזה, עד {max, plural, one {שדה אחד} two {שני שדות} other {# שדות}}. רשימה לא יכולה להכיל רשימה אחרת.", + add: "הוספת שדה פריט", + addTitle: "הוספת שדה פריט", + editTitle: "עריכת שדה פריט", + dialogDescription: "תוויות וטקסט עזרה מוצגים בכל פריט בעורך.", + addApply: "הוספה", + removeSaved: "הסרה דרך התפריט של השדה", + maxItems: "לכל היותר", + maxItemsHint: "כמה פריטים יכולים להיות ברשומה, עד {max, number}. ריק: 20.", + itemLabel: "שם הפריט", + itemLabelHint: "השדה שנותן שם לכל פריט בעורך ובאישורים.", + itemLabelFirst: "שדה הטקסט, הבחירה או ההפניה הראשון", + errors: { + subFields: { + required: "הוסיפו לפחות שדה אחד לפריטים.", + limit: "רשימה מכילה לכל היותר עשרה שדות לכל פריט.", + invalid: "אחד משדות הפריט צריך תיקון.", + }, + sub: "נדרשים תווית, מפתח פנוי, ובהפניה גם אוסף.", + maxItems: "הזינו מספר שלם מ-1 עד {max, number}.", + itemLabel: "בחרו שדה טקסט, בחירה או הפניה.", + }, + }, errors: { label: "תנו לשדה תווית.", maxLength: "הזינו מספר שלם מ־1 עד {max, number}.", @@ -241,6 +319,17 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "כל תרגום יקבל ערך משלו. עד שייקבע ערך, יוצג הערך של המקור.", newKey: "מפתח חדש", newType: "סוג חדש", + appliesTo: "חל על", + wholeField: "השדה כולו", + stuckTitle: + "{count, plural, one {רשומה אחת כבר לא ניתנת להמרה} two {שתי רשומות כבר לא ניתנות להמרה} other {# רשומות כבר לא ניתנות להמרה}}", + stuckRun: + "הערכים שלהן השתנו בזמן שהשדה שונה: רשומה שנבחרה הועברה לארכיון או נמחקה, או שכותרת תואמת עכשיו שתי רשומות. השינוי לא יכול להסתיים כל עוד הן שומרות על הערכים האלה.", + stuckRevert: + "הערכים שלהן נערכו אחרי השינוי ואי אפשר להמיר אותם בחזרה. הביטול לא יכול להסתיים כל עוד הן שומרות על הערכים האלה.", + stuckConfirm: + "נקו {count, plural, one {את הערך הזה} two {את שני הערכים האלה} other {את # הערכים האלה}} כדי לסיים. הם נשמרים ב-snapshot שנוצר לפני השינוי.", + stuckFinish: "ניקוי וסיום", check: "בדיקת מה משתנה", checking: "בודקים רשומות…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.hi.ts b/apps/studio/src/i18n/lazy/fields.hi.ts index 73559ad8..48f0e86d 100644 --- a/apps/studio/src/i18n/lazy/fields.hi.ts +++ b/apps/studio/src/i18n/lazy/fields.hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 0a3a4a09af501ba4 +// hash: 7141da51f94098d0 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "लिंक", image: "इमेज", select: "विकल्प", + reference: "रेफ़रेंस", + repeater: "सूची", }, editor: { title: "फ़ील्ड", @@ -118,6 +120,31 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {जोड़ें} one {# एंट्री जोड़ें} other {# एंट्री जोड़ें}}", }, }, + publish: { + title: "{count, plural, one {# रेफ़रेंस कुछ नहीं दिखाएगा} other {# रेफ़रेंस कुछ नहीं दिखाएँगे}}", + description: "प्रकाशन जारी रहेगा। ये प्रकाशित एंट्री ऐसी एंट्री चुनती हैं जिसे साइट नहीं दिखाएगी।", + issue: + "{language} · {field}: {reason, select, missing {चुनी गई एंट्री अब मौजूद नहीं है} hidden {चुनी गई एंट्री प्रकाशित नहीं है} locale {इस भाषा में कोई संस्करण नहीं} other {नहीं दिखेगी}}", + }, + delete: { + blockedTitle: + "{count, plural, one {पहले इस एंट्री को अप्रकाशित करें या संपादित करें} other {पहले इन # एंट्री को अप्रकाशित करें या संपादित करें}}", + blocked: + "प्रकाशित पेज अपने रेफ़रेंस फ़ील्ड के ज़रिए इस एंट्री को दिखाते हैं, और यही आख़िरी एंट्री है जिसे वे दिखा सकते हैं। इसे आर्काइव करने से कुछ नहीं होगा: वे अब भी इसी की ओर इशारा करते हैं। उन्हें बदलें या अप्रकाशित करें, फिर हटाएँ।", + moreReferences: "{count, plural, one {और # रेफ़रेंस।} other {और # रेफ़रेंस।}}", + shownByPublished: "{languages} में प्रकाशित वर्ज़न इसे दिखाता है", + draftsTitle: + "{count, plural, one {# ड्राफ़्ट इस एंट्री को चुनता है} other {# ड्राफ़्ट इस एंट्री को चुनते हैं}}", + drafts: + "इस समूह की कोई दूसरी एंट्री नहीं बची है, इसलिए जब तक वे कोई दूसरी एंट्री न चुनें, उनका फ़ील्ड कुछ नहीं दिखाएगा।", + showsNothing: "इस फ़ील्ड में कुछ नहीं दिखेगा", + localeTitle: "{language} पेज से यह कार्ड हट जाएगा", + locale: "{language} की जो एंट्री इस एंट्री को किसी रेफ़रेंस फ़ील्ड में दिखाती हैं, वे वहाँ कुछ नहीं दिखाएँगी।", + localeFallback: + "{language} की जो एंट्री इस एंट्री को किसी रेफ़रेंस फ़ील्ड में दिखाती हैं, वे इसकी जगह {fallback} संस्करण दिखाएँगी।", + where: "{collection} · {field}", + caption: "इस एंट्री को दिखाने वाली एंट्री", + }, usedBy: { title: "इस्तेमाल करने वाली एंट्री", description: "वे एंट्री जिनके फ़ील्ड इस एंट्री को रेफ़र करते हैं, प्रकाशित पहले।", @@ -206,6 +233,55 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "एंट्री एक से ज़्यादा विकल्प चुन सकती हैं।", add: "फ़ील्ड जोड़ें", apply: "लागू करें", + reference: { + collections: "लक्ष्य संग्रह", + collectionsHint: + "इन संग्रहों की एंट्री चुनी जा सकती हैं, {max, plural, one {# संग्रह} other {# संग्रह}} तक।", + multiple: "कई", + multipleHint: "एंट्री एक से ज़्यादा चुन सकती हैं, अपने ही क्रम में।", + maxItems: "अधिकतम", + maxItemsHint: "कितनी चुनी जा सकती हैं, {max, number} तक। खाली: 20।", + where: "केवल वे एंट्री जिनमें", + whereHint: "वे शर्तें जो नई चुनी गई एंट्री पूरी करती है। पहले चुनी गई एंट्री अपनी जगह बनी रहती हैं।", + whereOneCollection: "शर्तें तभी लागू होती हैं जब एक ही लक्ष्य संग्रह हो।", + whereNoListFields: + "{collection} में शर्तें तय करने के लिए कोई लिस्टिंग फ़ील्ड नहीं है। उन्हें उसकी सेटिंग में जोड़ें।", + addCondition: "शर्त जोड़ें", + errors: { + collections: { + required: "कम से कम एक संग्रह चुनें।", + limit: "अधिकतम पाँच संग्रह चुनें।", + }, + maxItems: "1 से {max, number} तक की पूर्ण संख्या डालें।", + where: "कोई शर्त ऐसे फ़ील्ड का उपयोग करती है जो लक्ष्य संग्रह का लिस्टिंग फ़ील्ड नहीं है।", + }, + }, + repeater: { + subFields: "हर आइटम के फ़ील्ड", + subFieldsHint: + "हर आइटम में ये फ़ील्ड इसी क्रम में होते हैं, {max, plural, one {# फ़ील्ड} other {# फ़ील्ड}} तक। एक सूची में दूसरी सूची नहीं रखी जा सकती।", + add: "आइटम फ़ील्ड जोड़ें", + addTitle: "आइटम फ़ील्ड जोड़ें", + editTitle: "आइटम फ़ील्ड संपादित करें", + dialogDescription: "लेबल और मदद एडिटर में हर आइटम पर दिखते हैं।", + addApply: "जोड़ें", + removeSaved: "फ़ील्ड के मेन्यू से हटाएँ", + maxItems: "अधिकतम", + maxItemsHint: "एक एंट्री में कितने आइटम हो सकते हैं, {max, number} तक। खाली: 20।", + itemLabel: "आइटम का नाम", + itemLabelHint: "वह फ़ील्ड जो एडिटर और अनुमोदन में हर आइटम का नाम बताता है।", + itemLabelFirst: "पहला टेक्स्ट, विकल्प या रेफ़रेंस", + errors: { + subFields: { + required: "आइटम के लिए कम से कम एक फ़ील्ड जोड़ें।", + limit: "एक सूची में हर आइटम के अधिकतम दस फ़ील्ड होते हैं।", + invalid: "किसी एक आइटम फ़ील्ड को ठीक करना ज़रूरी है।", + }, + sub: "लेबल, कोई ऐसी कुंजी जो इस्तेमाल में न हो, और रेफ़रेंस के लिए एक संग्रह चाहिए।", + maxItems: "1 से {max, number} तक की पूर्ण संख्या डालें।", + itemLabel: "कोई टेक्स्ट, विकल्प या रेफ़रेंस फ़ील्ड चुनें।", + }, + }, errors: { label: "फ़ील्ड को एक लेबल दें।", maxLength: "1 से {max, number} तक की पूर्ण संख्या डालें।", @@ -242,6 +318,17 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "हर अनुवाद को अपना मान मिलेगा। जब तक कोई मान न डाला जाए, वह मूल का मान दिखाएगा।", newKey: "नई कुंजी", newType: "नया प्रकार", + appliesTo: "किस पर लागू", + wholeField: "पूरा फ़ील्ड", + stuckTitle: + "{count, plural, one {# एंट्री अब बदली नहीं जा सकती} other {# एंट्री अब बदली नहीं जा सकतीं}}", + stuckRun: + "फ़ील्ड बदलते समय उनके मान बदल गए: कोई चुनी गई एंट्री आर्काइव की गई या हटा दी गई, या कोई शीर्षक अब दो एंट्री से मेल खाता है। जब तक उनके ये मान रहेंगे, बदलाव पूरा नहीं हो सकता।", + stuckRevert: + "बदलाव के बाद उनके मान संपादित किए गए, इसलिए उन्हें वापस नहीं बदला जा सकता। जब तक उनके ये मान रहेंगे, वापस लेना पूरा नहीं हो सकता।", + stuckConfirm: + "पूरा करने के लिए {count, plural, one {यह मान} other {ये # मान}} मिटाएँ। वे बदलाव से पहले लिए गए स्नैपशॉट में बने रहते हैं।", + stuckFinish: "मिटाएँ और पूरा करें", check: "देखें क्या बदलेगा", checking: "एंट्री जाँची जा रही हैं…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.id.ts b/apps/studio/src/i18n/lazy/fields.id.ts index 4cdc6ef9..48ff7899 100644 --- a/apps/studio/src/i18n/lazy/fields.id.ts +++ b/apps/studio/src/i18n/lazy/fields.id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 72420152e87ac4c7 +// hash: 4a4f044fe65eef1b import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Tautan", image: "Gambar", select: "Pilihan", + reference: "Referensi", + repeater: "Daftar", }, editor: { title: "Kolom", @@ -118,6 +120,31 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Tambah} other {Tambah # entri}}", }, }, + publish: { + title: "{count, plural, other {# referensi tidak akan menampilkan apa pun}}", + description: + "Penerbitan tetap berjalan. Entri terbit ini menunjuk entri yang tidak akan ditampilkan situs.", + issue: + "{language} · {field}: {reason, select, missing {entri yang dipilih sudah tidak ada} hidden {entri yang dipilih belum terbit} locale {tidak ada versi dalam bahasa ini} other {tidak ditampilkan}}", + }, + delete: { + blockedTitle: "{count, plural, other {Batalkan penerbitan atau edit # entri ini dulu}}", + blocked: + "Halaman yang terbit menampilkan entri ini lewat kolom referensinya, dan ini entri terakhir yang bisa mereka tampilkan. Mengarsipkannya tidak membantu: halaman tersebut tetap menunjuknya. Ubah atau batalkan penerbitannya, lalu hapus.", + moreReferences: "{count, plural, other {Dan # referensi lainnya.}}", + shownByPublished: "Ditampilkan oleh versi terbitnya dalam {languages}", + draftsTitle: "{count, plural, other {# draf menunjuk entri ini}}", + drafts: + "Tidak ada lagi entri lain di grup ini, jadi kolomnya tidak menampilkan apa pun sampai draf tersebut memilih yang lain.", + showsNothing: "Tidak akan menampilkan apa pun untuk kolom ini", + localeTitle: "Halaman {language} kehilangan kartu ini", + locale: + "Entri dalam {language} yang menampilkan entri ini di kolom referensi tidak akan menampilkan apa pun di sana.", + localeFallback: + "Entri dalam {language} yang menampilkan entri ini di kolom referensi akan menampilkan versi {fallback} sebagai gantinya.", + where: "{collection} · {field}", + caption: "Entri yang menampilkan entri ini", + }, usedBy: { title: "Dipakai oleh", description: "Entri yang kolomnya merujuk ke entri ini, yang sudah terbit lebih dulu.", @@ -211,6 +238,57 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Entri bisa memilih lebih dari satu opsi.", add: "Tambah kolom", apply: "Terapkan", + reference: { + collections: "Koleksi tujuan", + collectionsHint: + "Entri dari koleksi ini bisa dipilih, hingga {max, plural, other {# koleksi}}.", + multiple: "Beberapa", + multipleHint: "Entri bisa memilih lebih dari satu, dengan urutannya sendiri.", + maxItems: "Maksimal", + maxItemsHint: "Berapa banyak yang bisa dipilih, hingga {max, number}. Kosong: 20.", + where: "Hanya entri yang", + whereHint: + "Kondisi yang harus dipenuhi entri yang baru dipilih. Entri yang dipilih sebelumnya tetap di tempatnya.", + whereOneCollection: "Kondisi berlaku jika hanya ada satu koleksi tujuan.", + whereNoListFields: + "{collection} tidak punya kolom daftar untuk diberi kondisi. Tambahkan di pengaturannya.", + addCondition: "Tambah kondisi", + errors: { + collections: { + required: "Pilih setidaknya satu koleksi.", + limit: "Pilih maksimal lima koleksi.", + }, + maxItems: "Masukkan bilangan bulat dari 1 sampai {max, number}.", + where: "Sebuah kondisi memakai kolom yang bukan kolom daftar di koleksi tujuan.", + }, + }, + repeater: { + subFields: "Kolom setiap item", + subFieldsHint: + "Setiap item punya kolom ini, dengan urutan ini, hingga {max, plural, other {# kolom}}. Daftar tidak bisa memuat daftar lain.", + add: "Tambah kolom item", + addTitle: "Tambah kolom item", + editTitle: "Edit kolom item", + dialogDescription: "Label dan bantuan tampil di setiap item dalam editor.", + addApply: "Tambah", + removeSaved: "Hapus lewat menu kolom", + maxItems: "Maksimal", + maxItemsHint: + "Berapa banyak item yang bisa dimiliki sebuah entri, hingga {max, number}. Kosong: 20.", + itemLabel: "Nama item", + itemLabelHint: "Kolom yang menamai setiap item di editor dan di persetujuan.", + itemLabelFirst: "Teks, pilihan, atau referensi pertama", + errors: { + subFields: { + required: "Tambahkan setidaknya satu kolom untuk item.", + limit: "Daftar memuat maksimal sepuluh kolom per item.", + invalid: "Salah satu kolom item perlu diperbaiki.", + }, + sub: "Perlu label, kunci yang belum dipakai, dan untuk referensi, sebuah koleksi.", + maxItems: "Masukkan bilangan bulat dari 1 sampai {max, number}.", + itemLabel: "Pilih kolom teks, pilihan, atau referensi.", + }, + }, errors: { label: "Beri kolom ini label.", maxLength: "Masukkan bilangan bulat dari 1 sampai {max, number}.", @@ -250,6 +328,16 @@ export const messages: NamespaceMessages<"fields"> = { "Setiap terjemahan mendapat nilainya sendiri. Sampai diisi, terjemahan menampilkan nilai versi asli.", newKey: "Kunci baru", newType: "Jenis baru", + appliesTo: "Berlaku untuk", + wholeField: "Seluruh kolom", + stuckTitle: "{count, plural, other {# entri tidak bisa dikonversi lagi}}", + stuckRun: + "Nilainya berubah saat kolom sedang diubah: entri yang dipilih diarsipkan atau dihapus, atau sebuah judul kini cocok dengan dua entri. Perubahan tidak bisa selesai selama nilai ini masih ada.", + stuckRevert: + "Nilainya diedit setelah perubahan dan tidak bisa dikonversi kembali. Pembatalan tidak bisa selesai selama nilai ini masih ada.", + stuckConfirm: + "Kosongkan {count, plural, other {# nilai ini}} untuk menyelesaikan. Nilainya tetap tersimpan di snapshot yang dibuat sebelum perubahan.", + stuckFinish: "Kosongkan dan selesaikan", check: "Lihat apa yang berubah", checking: "Memeriksa entri…", clear: "{count, plural, other {Kosongkan # nilai yang tidak bisa dikonversi}}", diff --git a/apps/studio/src/i18n/lazy/fields.it.ts b/apps/studio/src/i18n/lazy/fields.it.ts index 849eb482..8b315ead 100644 --- a/apps/studio/src/i18n/lazy/fields.it.ts +++ b/apps/studio/src/i18n/lazy/fields.it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 813e78eb09b8af0b +// hash: 2422dd0eae94612d import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Link", image: "Immagine", select: "Scelta", + reference: "Riferimento", + repeater: "Elenco", }, editor: { title: "Campi", @@ -122,6 +124,35 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Aggiungi} one {Aggiungi # voce} many {Aggiungi # di voci} other {Aggiungi # voci}}", }, }, + publish: { + title: + "{count, plural, one {# riferimento non mostrerà nulla} many {# di riferimenti non mostreranno nulla} other {# riferimenti non mostreranno nulla}}", + description: + "La pubblicazione procede. Queste voci pubblicate indicano una voce che il sito non mostrerà.", + issue: + "{language} · {field}: {reason, select, missing {la voce scelta non esiste più} hidden {la voce scelta non è pubblicata} locale {nessuna versione in questa lingua} other {non mostrata}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Prima annulla la pubblicazione di questa voce o modificala} many {Prima annulla la pubblicazione di queste # di voci o modificale} other {Prima annulla la pubblicazione di queste # voci o modificale}}", + blocked: + "Alcune pagine pubblicate mostrano questa voce tramite i loro campi di riferimento, ed è l’ultima voce che possono mostrare. Archiviarla non basta: continuano a indicarla. Modificale o annullane la pubblicazione, poi elimina.", + moreReferences: + "{count, plural, one {E # altro riferimento.} many {E altri # di riferimenti.} other {E altri # riferimenti.}}", + shownByPublished: "Mostrata dalla sua versione pubblicata in {languages}", + draftsTitle: + "{count, plural, one {# bozza indica questa voce} many {# di bozze indicano questa voce} other {# bozze indicano questa voce}}", + drafts: + "Non resta nessun’altra voce di questo gruppo, quindi il loro campo non mostra nulla finché non ne scelgono un’altra.", + showsNothing: "Non mostrerà nulla per questo campo", + localeTitle: "Le pagine in {language} perdono questa scheda", + locale: + "Le voci in {language} che mostrano questa voce in un campo di riferimento non mostreranno nulla lì.", + localeFallback: + "Le voci in {language} che mostrano questa voce in un campo di riferimento mostreranno invece la versione {fallback}.", + where: "{collection} · {field}", + caption: "Voci che mostrano questa voce", + }, usedBy: { title: "Usata da", description: "Voci i cui campi fanno riferimento a questa, prima quelle pubblicate.", @@ -216,6 +247,57 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Le voci possono scegliere più di un’opzione.", add: "Aggiungi campo", apply: "Applica", + reference: { + collections: "Raccolte di destinazione", + collectionsHint: + "Si possono scegliere voci di queste raccolte, fino a {max, plural, one {# raccolta} many {# di raccolte} other {# raccolte}}.", + multiple: "Più voci", + multipleHint: "Le voci possono sceglierne più di una, in un ordine proprio.", + maxItems: "Al massimo", + maxItemsHint: "Quante se ne possono scegliere, fino a {max, number}. Vuoto: 20.", + where: "Solo voci in cui", + whereHint: + "Condizioni che una voce appena scelta deve rispettare. Le voci scelte prima mantengono il loro posto.", + whereOneCollection: "Le condizioni si applicano con una sola raccolta di destinazione.", + whereNoListFields: + "{collection} non ha campi di elenco su cui impostare condizioni. Aggiungili nelle sue impostazioni.", + addCondition: "Aggiungi condizione", + errors: { + collections: { + required: "Scegli almeno una raccolta.", + limit: "Scegli al massimo cinque raccolte.", + }, + maxItems: "Inserisci un numero intero da 1 a {max, number}.", + where: + "Una condizione usa un campo che non è un campo di elenco della raccolta di destinazione.", + }, + }, + repeater: { + subFields: "Campi di ogni elemento", + subFieldsHint: + "Ogni elemento li ha, in quest’ordine, fino a {max, plural, one {# campo} many {# di campi} other {# campi}}. Un elenco non può contenere un altro elenco.", + add: "Aggiungi campo dell’elemento", + addTitle: "Aggiungi campo dell’elemento", + editTitle: "Modifica campo dell’elemento", + dialogDescription: "Etichette e aiuto compaiono su ogni elemento nell’editor.", + addApply: "Aggiungi", + removeSaved: "Rimuovi dal menu del campo", + maxItems: "Al massimo", + maxItemsHint: "Quanti elementi può avere una voce, fino a {max, number}. Vuoto: 20.", + itemLabel: "Nome dell’elemento", + itemLabelHint: "Il campo che dà il nome a ogni elemento nell’editor e nelle approvazioni.", + itemLabelFirst: "Il primo campo di testo, scelta o riferimento", + errors: { + subFields: { + required: "Aggiungi almeno un campo per gli elementi.", + limit: "Un elenco contiene al massimo dieci campi per elemento.", + invalid: "Uno dei campi dell’elemento va corretto.", + }, + sub: "Servono un’etichetta, una chiave libera e, per un riferimento, una raccolta.", + maxItems: "Inserisci un numero intero da 1 a {max, number}.", + itemLabel: "Scegli un campo di testo, scelta o riferimento.", + }, + }, errors: { label: "Dai un’etichetta al campo.", maxLength: "Inserisci un numero intero da 1 a {max, number}.", @@ -255,6 +337,17 @@ export const messages: NamespaceMessages<"fields"> = { "Ogni traduzione avrà un proprio valore. Finché non ne viene impostato uno, mostra quello dell’originale.", newKey: "Nuova chiave", newType: "Nuovo tipo", + appliesTo: "Si applica a", + wholeField: "L’intero campo", + stuckTitle: + "{count, plural, one {# voce non si converte più} many {# di voci non si convertono più} other {# voci non si convertono più}}", + stuckRun: + "I loro valori sono cambiati mentre il campo veniva modificato: una voce scelta è stata archiviata o eliminata, oppure un titolo ora corrisponde a due voci. La modifica non può terminare finché mantengono questi valori.", + stuckRevert: + "I loro valori sono stati cambiati dopo la modifica e non si possono riconvertire. L’annullamento non può terminare finché mantengono questi valori.", + stuckConfirm: + "Svuota {count, plural, one {questo valore} many {questi # di valori} other {questi # valori}} per terminare. Restano nello snapshot creato prima della modifica.", + stuckFinish: "Svuota e termina", check: "Controlla cosa cambia", checking: "Controllo delle voci…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.ja.ts b/apps/studio/src/i18n/lazy/fields.ja.ts index 97833562..77a11c78 100644 --- a/apps/studio/src/i18n/lazy/fields.ja.ts +++ b/apps/studio/src/i18n/lazy/fields.ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: c4c678804d4325e0 +// hash: 7c8e52933c262a59 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "リンク", image: "画像", select: "選択", + reference: "参照", + repeater: "リスト", }, editor: { title: "フィールド", @@ -120,6 +122,31 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {追加} other {#件のエントリーを追加}}", }, }, + publish: { + title: "{count, plural, other {#件の参照に何も表示されません}}", + description: + "公開は続行されます。これらの公開済みエントリーは、サイトに表示されないエントリーを指しています。", + issue: + "{language} · {field}:{reason, select, missing {選択したエントリーがありません} hidden {選択したエントリーが公開されていません} locale {この言語のバージョンがありません} other {表示されません}}", + }, + delete: { + blockedTitle: "{count, plural, other {先にエントリー#件を公開停止するか編集してください}}", + blocked: + "公開済みのページが参照フィールドでこのエントリーを表示しており、表示できるのはこのエントリーだけです。アーカイブしても解決しません。引き続きこのエントリーを指しているためです。それらを変更するか公開停止してから、削除してください。", + moreReferences: "{count, plural, other {ほか#件の参照。}}", + shownByPublished: "公開済みのバージョンで表示: {languages}", + draftsTitle: "{count, plural, other {#件の下書きがこのエントリーを指しています}}", + drafts: + "このグループには他のエントリーが残っていないため、別のエントリーを選ぶまで、これらの下書きのフィールドには何も表示されません。", + showsNothing: "このフィールドには何も表示されなくなります", + localeTitle: "{language}のページからこのカードがなくなります", + locale: + "参照フィールドでこのエントリーを表示している{language}のエントリーでは、その場所に何も表示されなくなります。", + localeFallback: + "参照フィールドでこのエントリーを表示している{language}のエントリーでは、代わりに{fallback}のバージョンが表示されます。", + where: "{collection} · {field}", + caption: "このエントリーを表示しているエントリー", + }, usedBy: { title: "参照元", description: @@ -213,6 +240,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "エントリーは複数の選択肢を選べます。", add: "フィールドを追加", apply: "適用", + reference: { + collections: "対象コレクション", + collectionsHint: + "これらのコレクションのエントリーを選択できます(最大{max, plural, other {#個のコレクション}})。", + multiple: "複数", + multipleHint: "エントリーは複数を独自の順序で選択できます。", + maxItems: "最大", + maxItemsHint: "選択できる数です(最大{max, number})。空欄の場合:20。", + where: "次の条件に合うエントリーのみ", + whereHint: + "新しく選択するエントリーが満たす条件です。以前に選択したエントリーはそのまま残ります。", + whereOneCollection: "条件は対象コレクションが1つの場合に適用されます。", + whereNoListFields: + "{collection}には条件を設定できるリスト用フィールドがありません。コレクションの設定で追加してください。", + addCondition: "条件を追加", + errors: { + collections: { + required: "コレクションを1つ以上選択してください。", + limit: "コレクションは5つまで選択できます。", + }, + maxItems: "1 から {max, number} までの整数を入力してください。", + where: "条件に、対象コレクションのリスト用フィールドではないフィールドが使われています。", + }, + }, + repeater: { + subFields: "各項目のフィールド", + subFieldsHint: + "すべての項目がこれらをこの順序で持ちます(最大{max, plural, other {#個のフィールド}})。リストの中に別のリストは入れられません。", + add: "項目フィールドを追加", + addTitle: "項目フィールドを追加", + editTitle: "項目フィールドを編集", + dialogDescription: "ラベルとヘルプはエディターの各項目に表示されます。", + addApply: "追加", + removeSaved: "フィールドのメニューから削除", + maxItems: "最大", + maxItemsHint: "1つのエントリーが持てる項目の数です(最大{max, number})。空欄の場合:20。", + itemLabel: "項目名", + itemLabelHint: "エディターと承認で各項目の名前になるフィールドです。", + itemLabelFirst: "最初のテキスト、選択、参照のフィールド", + errors: { + subFields: { + required: "項目のフィールドを1つ以上追加してください。", + limit: "リストの項目あたりのフィールドは10個までです。", + invalid: "項目フィールドの1つに修正が必要です。", + }, + sub: "ラベル、未使用のキー、参照の場合はコレクションが必要です。", + maxItems: "1 から {max, number} までの整数を入力してください。", + itemLabel: "テキスト、選択、参照のいずれかのフィールドを選んでください。", + }, + }, errors: { label: "フィールドにラベルを付けてください。", maxLength: "1 から {max, number} までの整数を入力してください。", @@ -251,6 +328,16 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "各翻訳が独自の値を持ちます。値が設定されるまではオリジナルの値を表示します。", newKey: "新しいキー", newType: "新しい型", + appliesTo: "適用先", + wholeField: "フィールド全体", + stuckTitle: "{count, plural, other {#件のエントリーが変換できなくなりました}}", + stuckRun: + "フィールドの変更中に値が変わりました。選択したエントリーがアーカイブまたは削除されたか、タイトルが2つのエントリーに一致するようになっています。これらの値のままでは変更を完了できません。", + stuckRevert: + "変更後に値が編集されたため、元の形式に変換し直せません。これらの値のままでは元に戻す処理を完了できません。", + stuckConfirm: + "完了するには{count, plural, other {これらの#件の値}}を消去してください。値は変更前に作成されたスナップショットに残ります。", + stuckFinish: "消去して完了", check: "変更内容を確認", checking: "エントリーを確認しています…", clear: "{count, plural, other {変換できない # 件の値を消去する}}", diff --git a/apps/studio/src/i18n/lazy/fields.ko.ts b/apps/studio/src/i18n/lazy/fields.ko.ts index 58d48344..c2217b3f 100644 --- a/apps/studio/src/i18n/lazy/fields.ko.ts +++ b/apps/studio/src/i18n/lazy/fields.ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5e4e74aa73631583 +// hash: 7b1fe6891e58eed7 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "링크", image: "이미지", select: "선택", + reference: "참조", + repeater: "목록", }, editor: { title: "필드", @@ -116,6 +118,31 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {추가} other {항목 #개 추가}}", }, }, + publish: { + title: "{count, plural, other {참조 #개에 아무것도 표시되지 않아요}}", + description: + "게시는 계속 진행돼요. 이 게시된 항목들은 사이트에 표시되지 않을 항목을 가리켜요.", + issue: + "{language} · {field}: {reason, select, missing {선택한 항목이 없어졌어요} hidden {선택한 항목이 게시되지 않았어요} locale {이 언어의 버전이 없어요} other {표시되지 않아요}}", + }, + delete: { + blockedTitle: "{count, plural, other {먼저 항목 #개를 게시 취소하거나 편집하세요}}", + blocked: + "게시된 페이지가 참조 필드로 이 항목을 표시하고 있고, 표시할 수 있는 항목이 이것뿐이에요. 보관해도 소용없어요. 여전히 이 항목을 가리키기 때문이에요. 해당 페이지를 바꾸거나 게시 취소한 다음 삭제하세요.", + moreReferences: "{count, plural, other {외 참조 #개.}}", + shownByPublished: "게시된 버전으로 표시되는 언어: {languages}", + draftsTitle: "{count, plural, other {초안 #개가 이 항목을 가리켜요}}", + drafts: + "이 그룹에 다른 항목이 남아 있지 않아서, 다른 항목을 고를 때까지 해당 필드에 아무것도 표시되지 않아요.", + showsNothing: "이 필드에 아무것도 표시되지 않아요", + localeTitle: "{language} 페이지에서 이 카드가 사라져요", + locale: + "참조 필드에 이 항목을 표시하는 {language} 항목에는 그 자리에 아무것도 표시되지 않아요.", + localeFallback: + "참조 필드에 이 항목을 표시하는 {language} 항목에는 대신 {fallback} 버전이 표시돼요.", + where: "{collection} · {field}", + caption: "이 항목을 표시하는 항목", + }, usedBy: { title: "참조하는 항목", description: "필드에서 이 항목을 참조하는 항목이에요. 게시된 항목이 먼저 표시돼요.", @@ -205,6 +232,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "항목이 둘 이상의 옵션을 고를 수 있어요.", add: "필드 추가", apply: "적용", + reference: { + collections: "대상 컬렉션", + collectionsHint: + "이 컬렉션들의 항목을 선택할 수 있어요. 최대 {max, plural, other {컬렉션 #개}}까지예요.", + multiple: "여러 개", + multipleHint: "항목이 둘 이상을 자체 순서로 선택할 수 있어요.", + maxItems: "최대", + maxItemsHint: "선택할 수 있는 개수로, 최대 {max, number}개예요. 비워 두면: 20.", + where: "다음 조건의 항목만", + whereHint: "새로 선택하는 항목이 충족하는 조건이에요. 이전에 선택한 항목은 그대로 남아요.", + whereOneCollection: "조건은 대상 컬렉션이 하나일 때 적용돼요.", + whereNoListFields: + "{collection}에는 조건을 정할 목록 필드가 없어요. 컬렉션 설정에서 추가하세요.", + addCondition: "조건 추가", + errors: { + collections: { + required: "컬렉션을 하나 이상 선택하세요.", + limit: "컬렉션은 최대 다섯 개까지 선택하세요.", + }, + maxItems: "1부터 {max, number}까지의 정수를 입력하세요.", + where: "조건에 대상 컬렉션의 목록 필드가 아닌 필드가 사용되었어요.", + }, + }, + repeater: { + subFields: "각 아이템의 필드", + subFieldsHint: + "모든 아이템이 이 필드들을 이 순서대로 가져요. 최대 {max, plural, other {필드 #개}}까지예요. 목록 안에 다른 목록을 넣을 수 없어요.", + add: "아이템 필드 추가", + addTitle: "아이템 필드 추가", + editTitle: "아이템 필드 편집", + dialogDescription: "레이블과 도움말은 편집기의 모든 아이템에 표시돼요.", + addApply: "추가", + removeSaved: "필드 메뉴에서 제거", + maxItems: "최대", + maxItemsHint: + "항목 하나가 가질 수 있는 아이템 수로, 최대 {max, number}개예요. 비워 두면: 20.", + itemLabel: "아이템 이름", + itemLabelHint: "편집기와 승인에서 각 아이템의 이름이 되는 필드예요.", + itemLabelFirst: "첫 번째 텍스트, 선택 또는 참조", + errors: { + subFields: { + required: "아이템에 필드를 하나 이상 추가하세요.", + limit: "목록의 아이템당 필드는 최대 10개예요.", + invalid: "아이템 필드 중 하나를 고쳐야 해요.", + }, + sub: "레이블, 사용하지 않은 키, 그리고 참조라면 컬렉션이 필요해요.", + maxItems: "1부터 {max, number}까지의 정수를 입력하세요.", + itemLabel: "텍스트, 선택 또는 참조 필드를 고르세요.", + }, + }, errors: { label: "필드에 레이블을 지정하세요.", maxLength: "1부터 {max, number}까지의 정수를 입력하세요.", @@ -242,6 +319,16 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "번역마다 자기 값을 갖게 돼요. 값을 정하기 전까지는 원본의 값을 보여 줘요.", newKey: "새 키", newType: "새 유형", + appliesTo: "적용 대상", + wholeField: "필드 전체", + stuckTitle: "{count, plural, other {항목 #개를 더 이상 변환할 수 없어요}}", + stuckRun: + "필드를 바꾸는 동안 값이 바뀌었어요. 선택한 항목이 보관되거나 삭제되었거나, 제목이 이제 두 항목과 일치해요. 이 값이 그대로인 동안에는 변경을 끝낼 수 없어요.", + stuckRevert: + "변경 후에 값이 편집되어 다시 변환할 수 없어요. 이 값이 그대로인 동안에는 되돌리기를 끝낼 수 없어요.", + stuckConfirm: + "끝내려면 {count, plural, other {이 값 #개}}를 지우세요. 값은 변경 전에 만든 스냅샷에 남아 있어요.", + stuckFinish: "지우고 끝내기", check: "바뀌는 내용 확인", checking: "항목을 확인하는 중…", clear: "{count, plural, other {변환할 수 없는 값 #개 지우기}}", diff --git a/apps/studio/src/i18n/lazy/fields.nl.ts b/apps/studio/src/i18n/lazy/fields.nl.ts index 37005a78..96734637 100644 --- a/apps/studio/src/i18n/lazy/fields.nl.ts +++ b/apps/studio/src/i18n/lazy/fields.nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 05bd1bbcf46050be +// hash: 17f62dbe984c9d96 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Link", image: "Afbeelding", select: "Keuze", + reference: "Verwijzing", + repeater: "Lijst", }, editor: { title: "Velden", @@ -122,6 +124,33 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Toevoegen} one {# item toevoegen} other {# items toevoegen}}", }, }, + publish: { + title: "{count, plural, one {# verwijzing toont niets} other {# verwijzingen tonen niets}}", + description: + "Het publiceren gaat door. Deze gepubliceerde items verwijzen naar een item dat de site niet toont.", + issue: + "{language} · {field}: {reason, select, missing {het gekozen item bestaat niet meer} hidden {het gekozen item is niet gepubliceerd} locale {geen versie in deze taal} other {niet getoond}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Trek eerst de publicatie van dit item in of bewerk het} other {Trek eerst de publicatie van deze # items in of bewerk ze}}", + blocked: + "Gepubliceerde pagina’s tonen dit item via hun verwijzingsvelden, en het is het laatste item dat ze kunnen tonen. Archiveren helpt niet: ze verwijzen er nog steeds naar. Wijzig ze of trek hun publicatie in, en verwijder het daarna.", + moreReferences: "{count, plural, one {En nog # verwijzing.} other {En nog # verwijzingen.}}", + shownByPublished: "Getoond door de gepubliceerde versie in {languages}", + draftsTitle: + "{count, plural, one {# concept verwijst naar dit item} other {# concepten verwijzen naar dit item}}", + drafts: + "Er is geen ander item van deze groep meer over, dus hun veld toont niets tot ze een ander kiezen.", + showsNothing: "Toont niets voor dit veld", + localeTitle: "Pagina’s in {language} verliezen deze kaart", + locale: + "Items in {language} die dit item in een verwijzingsveld tonen, tonen daar niets meer.", + localeFallback: + "Items in {language} die dit item in een verwijzingsveld tonen, tonen in plaats daarvan de versie in {fallback}.", + where: "{collection} · {field}", + caption: "Items die dit item tonen", + }, usedBy: { title: "Gebruikt door", description: "Items waarvan velden naar dit item verwijzen, gepubliceerde eerst.", @@ -216,6 +245,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Items kunnen meer dan één optie kiezen.", add: "Veld toevoegen", apply: "Toepassen", + reference: { + collections: "Doelcollecties", + collectionsHint: + "Items uit deze collecties kunnen worden gekozen, tot {max, plural, one {# collectie} other {# collecties}}.", + multiple: "Meerdere", + multipleHint: "Items kunnen er meer dan één kiezen, in een eigen volgorde.", + maxItems: "Maximaal", + maxItemsHint: "Hoeveel er kunnen worden gekozen, tot {max, number}. Leeg: 20.", + where: "Alleen items waarbij", + whereHint: + "Voorwaarden waaraan een nieuw gekozen item voldoet. Eerder gekozen items behouden hun plaats.", + whereOneCollection: "Voorwaarden gelden bij één doelcollectie.", + whereNoListFields: + "{collection} heeft geen lijstvelden om voorwaarden op in te stellen. Voeg ze toe in de instellingen ervan.", + addCondition: "Voorwaarde toevoegen", + errors: { + collections: { + required: "Kies minstens één collectie.", + limit: "Kies maximaal vijf collecties.", + }, + maxItems: "Voer een heel getal van 1 tot {max, number} in.", + where: "Een voorwaarde gebruikt een veld dat geen lijstveld van de doelcollectie is.", + }, + }, + repeater: { + subFields: "Velden van elk element", + subFieldsHint: + "Elk element heeft deze, in deze volgorde, tot {max, plural, one {# veld} other {# velden}}. Een lijst kan geen andere lijst bevatten.", + add: "Elementveld toevoegen", + addTitle: "Elementveld toevoegen", + editTitle: "Elementveld bewerken", + dialogDescription: "Labels en hulptekst staan bij elk element in de editor.", + addApply: "Toevoegen", + removeSaved: "Verwijderen via het veldmenu", + maxItems: "Maximaal", + maxItemsHint: "Hoeveel elementen een item kan hebben, tot {max, number}. Leeg: 20.", + itemLabel: "Elementnaam", + itemLabelHint: "Het veld dat elk element een naam geeft in de editor en in goedkeuringen.", + itemLabelFirst: "De eerste tekst, keuze of verwijzing", + errors: { + subFields: { + required: "Voeg minstens één veld voor de elementen toe.", + limit: "Een lijst bevat maximaal tien velden per element.", + invalid: "Een van de elementvelden moet worden verbeterd.", + }, + sub: "Heeft een label nodig, een vrije sleutel en, bij een verwijzing, een collectie.", + maxItems: "Voer een heel getal van 1 tot {max, number} in.", + itemLabel: "Kies een tekst-, keuze- of verwijzingsveld.", + }, + }, errors: { label: "Geef het veld een label.", maxLength: "Voer een heel getal van 1 tot {max, number} in.", @@ -255,6 +334,17 @@ export const messages: NamespaceMessages<"fields"> = { "Elke vertaling krijgt een eigen waarde. Tot er een is ingesteld, toont ze die van het origineel.", newKey: "Nieuwe sleutel", newType: "Nieuw type", + appliesTo: "Geldt voor", + wholeField: "Het hele veld", + stuckTitle: + "{count, plural, one {# item kan niet meer worden omgezet} other {# items kunnen niet meer worden omgezet}}", + stuckRun: + "Hun waarden zijn gewijzigd terwijl het veld werd aangepast: een gekozen item is gearchiveerd of verwijderd, of een titel komt nu overeen met twee items. De wijziging kan niet worden afgerond zolang ze deze waarden houden.", + stuckRevert: + "Hun waarden zijn na de wijziging bewerkt en kunnen niet worden teruggezet. Het terugdraaien kan niet worden afgerond zolang ze deze waarden houden.", + stuckConfirm: + "Wis {count, plural, one {deze waarde} other {deze # waarden}} om af te ronden. Ze blijven bewaard in de snapshot die voor de wijziging is gemaakt.", + stuckFinish: "Wissen en afronden", check: "Bekijken wat er verandert", checking: "Items controleren…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.pl.ts b/apps/studio/src/i18n/lazy/fields.pl.ts index 998fb2e2..ed749618 100644 --- a/apps/studio/src/i18n/lazy/fields.pl.ts +++ b/apps/studio/src/i18n/lazy/fields.pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: c96c375aa8dfddba +// hash: 71b99ea9e591bdbf import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Link", image: "Obraz", select: "Wybór", + reference: "Odwołanie", + repeater: "Lista", }, editor: { title: "Pola", @@ -121,6 +123,34 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Dodaj} one {Dodaj # wpis} few {Dodaj # wpisy} many {Dodaj # wpisów} other {Dodaj # wpisu}}", }, }, + publish: { + title: + "{count, plural, one {# odwołanie nic nie pokaże} few {# odwołania nic nie pokażą} many {# odwołań nic nie pokaże} other {# odwołania nic nie pokaże}}", + description: + "Publikacja zostanie wykonana. Te opublikowane wpisy wskazują wpis, którego witryna nie pokaże.", + issue: + "{language} · {field}: {reason, select, missing {wybrany wpis już nie istnieje} hidden {wybrany wpis nie jest opublikowany} locale {brak wersji w tym języku} other {niewidoczne}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Najpierw wycofaj publikację tego wpisu lub go edytuj} few {Najpierw wycofaj publikację tych # wpisów lub je edytuj} many {Najpierw wycofaj publikację tych # wpisów lub je edytuj} other {Najpierw wycofaj publikację tych # wpisów lub je edytuj}}", + blocked: + "Opublikowane strony pokazują ten wpis przez swoje pola odwołań i jest to ostatni wpis, jaki mogą pokazać. Archiwizacja nic nie da: nadal go wskazują. Zmień je lub wycofaj ich publikację, a potem usuń.", + moreReferences: + "{count, plural, one {I jeszcze # odwołanie.} few {I jeszcze # odwołania.} many {I jeszcze # odwołań.} other {I jeszcze # odwołania.}}", + shownByPublished: "Pokazywane przez opublikowaną wersję w językach: {languages}", + draftsTitle: + "{count, plural, one {# szkic wskazuje ten wpis} few {# szkice wskazują ten wpis} many {# szkiców wskazuje ten wpis} other {# szkicu wskazuje ten wpis}}", + drafts: + "Nie został żaden inny wpis z tej grupy, więc ich pole nic nie pokaże, dopóki nie wybiorą innego.", + showsNothing: "To pole nic nie pokaże", + localeTitle: "{language}: strony stracą tę kartę", + locale: "Wpisy ({language}), które pokazują ten wpis w polu odwołania, nic tam nie pokażą.", + localeFallback: + "Wpisy ({language}), które pokazują ten wpis w polu odwołania, pokażą zamiast niego wersję: {fallback}.", + where: "{collection} · {field}", + caption: "Wpisy, które pokazują ten wpis", + }, usedBy: { title: "Używany przez", description: "Wpisy, których pola odwołują się do tego wpisu, najpierw opublikowane.", @@ -214,6 +244,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Wpisy mogą wybrać więcej niż jedną opcję.", add: "Dodaj pole", apply: "Zastosuj", + reference: { + collections: "Kolekcje docelowe", + collectionsHint: + "Można wybierać wpisy z tych kolekcji. Limit: {max, plural, one {# kolekcja} few {# kolekcje} many {# kolekcji} other {# kolekcji}}.", + multiple: "Kilka", + multipleHint: "Wpisy mogą wybrać więcej niż jeden, we własnej kolejności.", + maxItems: "Maksymalnie", + maxItemsHint: "Ile można wybrać, do {max, number}. Puste: 20.", + where: "Tylko wpisy, w których", + whereHint: + "Warunki, które spełnia nowo wybrany wpis. Wpisy wybrane wcześniej zostają na swoim miejscu.", + whereOneCollection: "Warunki działają przy jednej kolekcji docelowej.", + whereNoListFields: + "Kolekcja {collection} nie ma pól list, na których można ustawić warunki. Dodaj je w jej ustawieniach.", + addCondition: "Dodaj warunek", + errors: { + collections: { + required: "Wybierz co najmniej jedną kolekcję.", + limit: "Wybierz najwyżej pięć kolekcji.", + }, + maxItems: "Wpisz liczbę całkowitą od 1 do {max, number}.", + where: "Warunek używa pola, które nie jest polem listy kolekcji docelowej.", + }, + }, + repeater: { + subFields: "Pola każdego elementu", + subFieldsHint: + "Każdy element ma te pola, w tej kolejności, do {max, plural, one {# pola} few {# pól} many {# pól} other {# pola}}. Lista nie może zawierać innej listy.", + add: "Dodaj pole elementu", + addTitle: "Dodaj pole elementu", + editTitle: "Edytuj pole elementu", + dialogDescription: "Etykiety i pomoc pojawiają się przy każdym elemencie w edytorze.", + addApply: "Dodaj", + removeSaved: "Usuń przez menu pola", + maxItems: "Maksymalnie", + maxItemsHint: "Ile elementów może mieć wpis, do {max, number}. Puste: 20.", + itemLabel: "Nazwa elementu", + itemLabelHint: "Pole, które nazywa każdy element w edytorze i w zatwierdzeniach.", + itemLabelFirst: "Pierwsze pole tekstu, wyboru lub odwołania", + errors: { + subFields: { + required: "Dodaj co najmniej jedno pole dla elementów.", + limit: "Lista mieści najwyżej dziesięć pól na element.", + invalid: "Jedno z pól elementu wymaga poprawki.", + }, + sub: "Potrzebna jest etykieta, wolny klucz, a dla odwołania kolekcja.", + maxItems: "Wpisz liczbę całkowitą od 1 do {max, number}.", + itemLabel: "Wybierz pole tekstu, wyboru lub odwołania.", + }, + }, errors: { label: "Nadaj polu etykietę.", maxLength: "Wpisz liczbę całkowitą od 1 do {max, number}.", @@ -253,6 +333,17 @@ export const messages: NamespaceMessages<"fields"> = { "Każde tłumaczenie dostanie własną wartość. Dopóki jej nie ustawisz, pokazuje wartość oryginału.", newKey: "Nowy klucz", newType: "Nowy typ", + appliesTo: "Dotyczy", + wholeField: "Całe pole", + stuckTitle: + "{count, plural, one {# wpisu nie da się już przekonwertować} few {# wpisów nie da się już przekonwertować} many {# wpisów nie da się już przekonwertować} other {# wpisu nie da się już przekonwertować}}", + stuckRun: + "Ich wartości zmieniły się w trakcie zmiany pola: wybrany wpis został zarchiwizowany lub usunięty albo tytuł pasuje teraz do dwóch wpisów. Zmiana nie może się zakończyć, dopóki mają te wartości.", + stuckRevert: + "Ich wartości edytowano po zmianie i nie da się ich przekonwertować z powrotem. Cofanie nie może się zakończyć, dopóki mają te wartości.", + stuckConfirm: + "Wyczyść {count, plural, one {tę wartość} few {te # wartości} many {te # wartości} other {te # wartości}}, aby zakończyć. Są zachowane w snapshocie wykonanym przed zmianą.", + stuckFinish: "Wyczyść i zakończ", check: "Sprawdź, co się zmieni", checking: "Sprawdzanie wpisów…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.pt-BR.ts b/apps/studio/src/i18n/lazy/fields.pt-BR.ts index 99ef3c6e..dfb4469a 100644 --- a/apps/studio/src/i18n/lazy/fields.pt-BR.ts +++ b/apps/studio/src/i18n/lazy/fields.pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: b533d0e4c3be5482 +// hash: 408b4545afb1bc5d import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Link", image: "Imagem", select: "Escolha", + reference: "Referência", + repeater: "Lista", }, editor: { title: "Campos", @@ -123,6 +125,35 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Adicionar} one {Adicionar # entrada} many {Adicionar # entradas} other {Adicionar # entradas}}", }, }, + publish: { + title: + "{count, plural, one {# referência não vai mostrar nada} many {# de referências não vão mostrar nada} other {# referências não vão mostrar nada}}", + description: + "A publicação continua. Estas entradas publicadas apontam para uma entrada que o site não vai mostrar.", + issue: + "{language} · {field}: {reason, select, missing {a entrada escolhida não existe mais} hidden {a entrada escolhida não está publicada} locale {não há versão neste idioma} other {não exibida}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Despublique ou edite esta entrada primeiro} many {Despublique ou edite estas # de entradas primeiro} other {Despublique ou edite estas # entradas primeiro}}", + blocked: + "Páginas publicadas mostram esta entrada pelos seus campos de referência, e ela é a última entrada que podem mostrar. Arquivá-la não resolve: elas continuam apontando para ela. Altere ou despublique essas páginas e depois exclua.", + moreReferences: + "{count, plural, one {E mais # referência.} many {E mais # de referências.} other {E mais # referências.}}", + shownByPublished: "Exibida pela versão publicada em: {languages}", + draftsTitle: + "{count, plural, one {# rascunho aponta para esta entrada} many {# de rascunhos apontam para esta entrada} other {# rascunhos apontam para esta entrada}}", + drafts: + "Não resta nenhuma outra entrada deste grupo, então o campo deles não mostra nada até escolherem outra.", + showsNothing: "Não vai mostrar nada neste campo", + localeTitle: "Páginas em {language} perdem este cartão", + locale: + "Entradas em {language} que mostram esta entrada em um campo de referência não vão mostrar nada ali.", + localeFallback: + "Entradas em {language} que mostram esta entrada em um campo de referência vão mostrar a versão em {fallback} no lugar dela.", + where: "{collection} · {field}", + caption: "Entradas que mostram esta entrada", + }, usedBy: { title: "Usada por", description: "Entradas cujos campos fazem referência a esta, com as publicadas primeiro.", @@ -217,6 +248,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "As entradas podem escolher mais de uma opção.", add: "Adicionar campo", apply: "Aplicar", + reference: { + collections: "Coleções de destino", + collectionsHint: + "Podem ser escolhidas entradas destas coleções, até {max, plural, one {# coleção} many {# de coleções} other {# coleções}}.", + multiple: "Várias", + multipleHint: "As entradas podem escolher mais de uma, na ordem que quiserem.", + maxItems: "No máximo", + maxItemsHint: "Quantas podem ser escolhidas, até {max, number}. Vazio: 20.", + where: "Só entradas em que", + whereHint: + "Condições que uma entrada recém-escolhida atende. As entradas escolhidas antes continuam onde estão.", + whereOneCollection: "As condições valem com uma única coleção de destino.", + whereNoListFields: + "{collection} não tem campos de listagem para definir condições. Adicione-os nas configurações dela.", + addCondition: "Adicionar condição", + errors: { + collections: { + required: "Escolha pelo menos uma coleção.", + limit: "Escolha no máximo cinco coleções.", + }, + maxItems: "Digite um número inteiro de 1 a {max, number}.", + where: "Uma condição usa um campo que não é um campo de listagem da coleção de destino.", + }, + }, + repeater: { + subFields: "Campos de cada item", + subFieldsHint: + "Todo item tem estes campos, nesta ordem, até {max, plural, one {# campo} many {# de campos} other {# campos}}. Uma lista não pode conter outra lista.", + add: "Adicionar campo do item", + addTitle: "Adicionar campo do item", + editTitle: "Editar campo do item", + dialogDescription: "Rótulos e ajuda aparecem em cada item no editor.", + addApply: "Adicionar", + removeSaved: "Remova pelo menu do campo", + maxItems: "No máximo", + maxItemsHint: "Quantos itens uma entrada pode ter, até {max, number}. Vazio: 20.", + itemLabel: "Nome do item", + itemLabelHint: "O campo que dá nome a cada item no editor e nas aprovações.", + itemLabelFirst: "O primeiro campo de texto, escolha ou referência", + errors: { + subFields: { + required: "Adicione pelo menos um campo para os itens.", + limit: "Uma lista comporta no máximo dez campos por item.", + invalid: "Um dos campos do item precisa de correção.", + }, + sub: "Precisa de um rótulo, uma chave livre e, para uma referência, uma coleção.", + maxItems: "Digite um número inteiro de 1 a {max, number}.", + itemLabel: "Escolha um campo de texto, escolha ou referência.", + }, + }, errors: { label: "Dê um rótulo ao campo.", maxLength: "Digite um número inteiro de 1 a {max, number}.", @@ -256,6 +337,17 @@ export const messages: NamespaceMessages<"fields"> = { "Cada tradução ganha um valor próprio. Até que um seja definido, ela mostra o do original.", newKey: "Nova chave", newType: "Novo tipo", + appliesTo: "Aplica-se a", + wholeField: "O campo inteiro", + stuckTitle: + "{count, plural, one {# entrada não pode mais ser convertida} many {# de entradas não podem mais ser convertidas} other {# entradas não podem mais ser convertidas}}", + stuckRun: + "Os valores delas mudaram enquanto o campo era alterado: uma entrada escolhida foi arquivada ou excluída, ou um título agora corresponde a duas entradas. A alteração não pode terminar enquanto elas mantiverem esses valores.", + stuckRevert: + "Os valores delas foram editados depois da alteração e não podem ser convertidos de volta. A reversão não pode terminar enquanto elas mantiverem esses valores.", + stuckConfirm: + "Limpe {count, plural, one {este valor} many {estes # de valores} other {estes # valores}} para terminar. Eles ficam guardados no snapshot criado antes da alteração.", + stuckFinish: "Limpar e terminar", check: "Ver o que muda", checking: "Conferindo entradas…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.ru.ts b/apps/studio/src/i18n/lazy/fields.ru.ts index 6417eba4..6e4a78cc 100644 --- a/apps/studio/src/i18n/lazy/fields.ru.ts +++ b/apps/studio/src/i18n/lazy/fields.ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 68b12b1016dd23d4 +// hash: f496953e41952b5d import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Ссылка", image: "Изображение", select: "Выбор", + reference: "Ссылка на запись", + repeater: "Список", }, editor: { title: "Поля", @@ -122,6 +124,35 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Добавить} one {Добавить # запись} few {Добавить # записи} many {Добавить # записей} other {Добавить # записи}}", }, }, + publish: { + title: + "{count, plural, one {# ссылка ничего не покажет} few {# ссылки ничего не покажут} many {# ссылок ничего не покажут} other {# ссылки ничего не покажут}}", + description: + "Публикация продолжится. Эти опубликованные записи ссылаются на запись, которую сайт не покажет.", + issue: + "{language} · {field}: {reason, select, missing {выбранной записи больше нет} hidden {выбранная запись не опубликована} locale {нет версии на этом языке} other {не показывается}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Сначала снимите с публикации или измените эту запись} few {Сначала снимите с публикации или измените эти # записи} many {Сначала снимите с публикации или измените эти # записей} other {Сначала снимите с публикации или измените эти # записи}}", + blocked: + "Опубликованные страницы показывают эту запись через свои поля-ссылки, и это последняя запись, которую они могут показать. Перенос в архив не поможет: они всё равно на неё ссылаются. Измените их или снимите с публикации, затем удалите.", + moreReferences: + "{count, plural, one {И ещё # ссылка.} few {И ещё # ссылки.} many {И ещё # ссылок.} other {И ещё # ссылки.}}", + shownByPublished: "Показывается опубликованной версией на языках: {languages}", + draftsTitle: + "{count, plural, one {# черновик ссылается на эту запись} few {# черновика ссылаются на эту запись} many {# черновиков ссылаются на эту запись} other {# черновика ссылаются на эту запись}}", + drafts: + "Других записей этой группы не осталось, поэтому их поле ничего не покажет, пока они не выберут другую.", + showsNothing: "В этом поле ничего не будет показано", + localeTitle: "{language}: страницы потеряют эту карточку", + locale: + "Язык {language}: записи, которые показывают эту запись в поле-ссылке, ничего там не покажут.", + localeFallback: + "Язык {language}: записи, которые показывают эту запись в поле-ссылке, покажут вместо неё версию: {fallback}.", + where: "{collection} · {field}", + caption: "Записи, которые показывают эту запись", + }, usedBy: { title: "Где используется", description: "Записи, поля которых ссылаются на эту; сначала опубликованные.", @@ -216,6 +247,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Записи могут выбрать больше одного варианта.", add: "Добавить поле", apply: "Применить", + reference: { + collections: "Целевые коллекции", + collectionsHint: + "Можно выбирать записи из этих коллекций, не больше {max, plural, one {# коллекции} few {# коллекций} many {# коллекций} other {# коллекции}}.", + multiple: "Несколько", + multipleHint: "Записи могут выбрать больше одной, в собственном порядке.", + maxItems: "Не больше", + maxItemsHint: "Сколько можно выбрать, до {max, number}. Пусто: 20.", + where: "Только записи, где", + whereHint: + "Условия, которым соответствует новая выбранная запись. Ранее выбранные записи остаются на месте.", + whereOneCollection: "Условия работают, когда целевая коллекция одна.", + whereNoListFields: + "В коллекции «{collection}» нет полей списков для условий. Добавьте их в её настройках.", + addCondition: "Добавить условие", + errors: { + collections: { + required: "Выберите хотя бы одну коллекцию.", + limit: "Выберите не больше пяти коллекций.", + }, + maxItems: "Введите целое число от 1 до {max, number}.", + where: "Условие использует поле, которое не является полем списка целевой коллекции.", + }, + }, + repeater: { + subFields: "Поля каждого элемента", + subFieldsHint: + "У каждого элемента есть эти поля в этом порядке, не больше {max, plural, one {# поля} few {# полей} many {# полей} other {# поля}}. Список не может содержать другой список.", + add: "Добавить поле элемента", + addTitle: "Добавить поле элемента", + editTitle: "Изменить поле элемента", + dialogDescription: "Подписи и подсказки видны у каждого элемента в редакторе.", + addApply: "Добавить", + removeSaved: "Удаляется через меню поля", + maxItems: "Не больше", + maxItemsHint: "Сколько элементов может быть у записи, до {max, number}. Пусто: 20.", + itemLabel: "Название элемента", + itemLabelHint: "Поле, которое называет каждый элемент в редакторе и в одобрениях.", + itemLabelFirst: "Первое поле текста, выбора или ссылки", + errors: { + subFields: { + required: "Добавьте хотя бы одно поле для элементов.", + limit: "В списке не больше десяти полей на элемент.", + invalid: "Одно из полей элемента нужно исправить.", + }, + sub: "Нужны подпись, свободный ключ, а для ссылки — коллекция.", + maxItems: "Введите целое число от 1 до {max, number}.", + itemLabel: "Выберите поле текста, выбора или ссылки.", + }, + }, errors: { label: "Дайте полю подпись.", maxLength: "Введите целое число от 1 до {max, number}.", @@ -255,6 +336,17 @@ export const messages: NamespaceMessages<"fields"> = { "Каждый перевод получит своё значение. Пока оно не задано, показывается значение оригинала.", newKey: "Новый ключ", newType: "Новый тип", + appliesTo: "Применяется к", + wholeField: "Всё поле", + stuckTitle: + "{count, plural, one {# запись больше не преобразуется} few {# записи больше не преобразуются} many {# записей больше не преобразуются} other {# записи больше не преобразуются}}", + stuckRun: + "Их значения изменились во время изменения поля: выбранную запись перенесли в архив или удалили, либо заголовок теперь совпадает с двумя записями. Изменение не может завершиться, пока у них эти значения.", + stuckRevert: + "Их значения отредактировали после изменения, и их нельзя преобразовать обратно. Отмена не может завершиться, пока у них эти значения.", + stuckConfirm: + "Очистите {count, plural, one {это значение} few {эти # значения} many {эти # значений} other {эти # значения}}, чтобы завершить. Они сохранены в snapshot, созданном до изменения.", + stuckFinish: "Очистить и завершить", check: "Проверить, что изменится", checking: "Проверка записей…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.tr.ts b/apps/studio/src/i18n/lazy/fields.tr.ts index 6a78ae55..e6af730b 100644 --- a/apps/studio/src/i18n/lazy/fields.tr.ts +++ b/apps/studio/src/i18n/lazy/fields.tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5bfce20b8d17e1f4 +// hash: 32247c6fc3da2d81 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Bağlantı", image: "Görsel", select: "Seçim", + reference: "Referans", + repeater: "Liste", }, editor: { title: "Alanlar", @@ -119,6 +121,34 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Ekle} one {# kayıt ekle} other {# kayıt ekle}}", }, }, + publish: { + title: + "{count, plural, one {# referans hiçbir şey göstermeyecek} other {# referans hiçbir şey göstermeyecek}}", + description: + "Yayımlama devam eder. Bu yayımlanmış kayıtlar, sitenin göstermeyeceği bir kayda referans veriyor.", + issue: + "{language} · {field}: {reason, select, missing {seçilen kayıt artık yok} hidden {seçilen kayıt yayımlanmamış} locale {bu dilde sürümü yok} other {gösterilmiyor}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Önce bu kaydı yayından kaldır ya da düzenle} other {Önce bu # kaydı yayından kaldır ya da düzenle}}", + blocked: + "Yayımlanmış sayfalar bu kaydı referans alanlarıyla gösteriyor ve bu, gösterebilecekleri son kayıt. Arşivlemek işe yaramaz: yine de bu kayda referans verirler. Onları değiştir ya da yayından kaldır, sonra sil.", + moreReferences: "{count, plural, one {Ve # referans daha.} other {Ve # referans daha.}}", + shownByPublished: "Yayımlanmış sürümü şu dillerde gösteriyor: {languages}", + draftsTitle: + "{count, plural, one {# taslak bu kayda referans veriyor} other {# taslak bu kayda referans veriyor}}", + drafts: + "Bu gruptan başka kayıt kalmadı, bu yüzden başka bir kayıt seçilene kadar bu taslakların alanı hiçbir şey göstermez.", + showsNothing: "Bu alanda hiçbir şey göstermeyecek", + localeTitle: "{language} sayfalar bu kartı kaybeder", + locale: + "Bu kaydı bir referans alanında gösteren {language} kayıtlar orada hiçbir şey göstermez.", + localeFallback: + "Bu kaydı bir referans alanında gösteren {language} kayıtlar bunun yerine şu sürümü gösterir: {fallback}.", + where: "{collection} · {field}", + caption: "Bu kaydı gösteren kayıtlar", + }, usedBy: { title: "Kullanıldığı yerler", description: "Alanları bu kayda referans veren kayıtlar; önce yayınlananlar.", @@ -212,6 +242,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Kayıtlar birden fazla seçenek seçebilir.", add: "Alan ekle", apply: "Uygula", + reference: { + collections: "Hedef koleksiyonlar", + collectionsHint: + "Bu koleksiyonların kayıtları seçilebilir, en fazla {max, plural, one {# koleksiyon} other {# koleksiyon}}.", + multiple: "Birden çok", + multipleHint: "Kayıtlar, kendi belirledikleri sırayla birden fazla kayıt seçebilir.", + maxItems: "En fazla", + maxItemsHint: "Kaç kayıt seçilebileceği, en fazla {max, number}. Boş: 20.", + where: "Yalnızca şu koşullara uyan kayıtlar", + whereHint: + "Yeni seçilen bir kaydın karşılaması gereken koşullar. Daha önce seçilen kayıtlar yerinde kalır.", + whereOneCollection: "Koşullar yalnızca tek bir hedef koleksiyonla geçerlidir.", + whereNoListFields: + "Bu koleksiyonda koşul koyulacak liste alanı yok: {collection}. Bunları koleksiyonun ayarlarından ekle.", + addCondition: "Koşul ekle", + errors: { + collections: { + required: "En az bir koleksiyon seç.", + limit: "En fazla beş koleksiyon seç.", + }, + maxItems: "1 ile {max, number} arasında bir tam sayı gir.", + where: "Bir koşul, hedef koleksiyonun liste alanı olmayan bir alan kullanıyor.", + }, + }, + repeater: { + subFields: "Her öğenin alanları", + subFieldsHint: + "Her öğede bu alanlar bu sırayla bulunur, en fazla {max, plural, one {# alan} other {# alan}}. Bir liste başka bir liste içeremez.", + add: "Öğe alanı ekle", + addTitle: "Öğe alanı ekle", + editTitle: "Öğe alanını düzenle", + dialogDescription: "Etiketler ve yardım metni düzenleyicide her öğede görünür.", + addApply: "Ekle", + removeSaved: "Alanın menüsünden kaldır", + maxItems: "En fazla", + maxItemsHint: "Bir kaydın kaç öğesi olabileceği, en fazla {max, number}. Boş: 20.", + itemLabel: "Öğe adı", + itemLabelHint: "Düzenleyicide ve onaylarda her öğeyi adlandıran alan.", + itemLabelFirst: "İlk metin, seçim ya da referans alanı", + errors: { + subFields: { + required: "Öğeler için en az bir alan ekle.", + limit: "Bir liste öğe başına en fazla on alan tutar.", + invalid: "Öğe alanlarından biri düzeltilmeli.", + }, + sub: "Bir etiket, kullanılmayan bir anahtar ve referans için bir koleksiyon gerekir.", + maxItems: "1 ile {max, number} arasında bir tam sayı gir.", + itemLabel: "Bir metin, seçim ya da referans alanı seç.", + }, + }, errors: { label: "Alana bir etiket ver.", maxLength: "1 ile {max, number} arasında bir tam sayı gir.", @@ -250,6 +330,17 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "Her çeviri kendi değerini alır. Biri girilene kadar orijinalinkini gösterir.", newKey: "Yeni anahtar", newType: "Yeni tür", + appliesTo: "Uygulandığı yer", + wholeField: "Alanın tamamı", + stuckTitle: + "{count, plural, one {# kayıt artık dönüştürülemiyor} other {# kayıt artık dönüştürülemiyor}}", + stuckRun: + "Alan değiştirilirken bu kayıtların değerleri değişti: seçilen bir kayıt arşivlendi ya da silindi veya bir başlık artık iki kayıtla eşleşiyor. Bu değerler kaldıkça değişiklik tamamlanamaz.", + stuckRevert: + "Bu kayıtların değerleri değişiklikten sonra düzenlendi ve geri dönüştürülemiyor. Bu değerler kaldıkça geri alma tamamlanamaz.", + stuckConfirm: + "Bitirmek için {count, plural, one {bu değeri} other {bu # değeri}} temizle. Değerler, değişiklikten önce alınan snapshot içinde saklanır.", + stuckFinish: "Temizle ve bitir", check: "Neyin değiştiğine bak", checking: "Kayıtlar kontrol ediliyor…", clear: "Dönüştürülemeyen {count, number} değeri temizle", diff --git a/apps/studio/src/i18n/lazy/fields.uk.ts b/apps/studio/src/i18n/lazy/fields.uk.ts index 6eaa477e..22405a2b 100644 --- a/apps/studio/src/i18n/lazy/fields.uk.ts +++ b/apps/studio/src/i18n/lazy/fields.uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: aed8cd7d8d9c49ea +// hash: bdd465df9370d286 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "Посилання", image: "Зображення", select: "Вибір", + reference: "Посилання на запис", + repeater: "Список", }, editor: { title: "Поля", @@ -122,6 +124,35 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {Додати} one {Додати # запис} few {Додати # записи} many {Додати # записів} other {Додати # запису}}", }, }, + publish: { + title: + "{count, plural, one {# посилання нічого не покаже} few {# посилання нічого не покажуть} many {# посилань нічого не покажуть} other {# посилання нічого не покажуть}}", + description: + "Публікація відбудеться. Ці опубліковані записи посилаються на запис, який сайт не покаже.", + issue: + "{language} · {field}: {reason, select, missing {вибраного запису вже немає} hidden {вибраний запис не опубліковано} locale {немає версії цією мовою} other {не показується}}", + }, + delete: { + blockedTitle: + "{count, plural, one {Спершу зніміть з публікації або змініть цей запис} few {Спершу зніміть з публікації або змініть ці # записи} many {Спершу зніміть з публікації або змініть ці # записів} other {Спершу зніміть з публікації або змініть ці # запису}}", + blocked: + "Опубліковані сторінки показують цей запис через свої поля посилань, і це останній запис, який вони можуть показати. Архівування не допоможе: вони все одно на нього посилаються. Змініть їх або зніміть з публікації, а тоді видаліть.", + moreReferences: + "{count, plural, one {І ще # посилання.} few {І ще # посилання.} many {І ще # посилань.} other {І ще # посилання.}}", + shownByPublished: "Показано опублікованою версією мовами: {languages}", + draftsTitle: + "{count, plural, one {# чернетка посилається на цей запис} few {# чернетки посилаються на цей запис} many {# чернеток посилаються на цей запис} other {# чернетки посилаються на цей запис}}", + drafts: + "Інших записів цієї групи не залишилося, тож їхнє поле нічого не показуватиме, доки вони не виберуть інший.", + showsNothing: "У цьому полі нічого не буде показано", + localeTitle: "{language}: сторінки втратять цю картку", + locale: + "Мова {language}: записи, які показують цей запис у полі посилання, нічого там не покажуть.", + localeFallback: + "Мова {language}: записи, які показують цей запис у полі посилання, натомість покажуть версію: {fallback}.", + where: "{collection} · {field}", + caption: "Записи, які показують цей запис", + }, usedBy: { title: "Де використовується", description: "Записи, поля яких посилаються на цей; спочатку опубліковані.", @@ -216,6 +247,56 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "Записи можуть вибрати більше ніж один варіант.", add: "Додати поле", apply: "Застосувати", + reference: { + collections: "Цільові колекції", + collectionsHint: + "Можна вибирати записи з цих колекцій, не більше {max, plural, one {# колекції} few {# колекцій} many {# колекцій} other {# колекції}}.", + multiple: "Кілька", + multipleHint: "Записи можуть вибрати більше ніж один, у власному порядку.", + maxItems: "Не більше", + maxItemsHint: "Скільки можна вибрати, до {max, number}. Порожньо: 20.", + where: "Лише записи, де", + whereHint: + "Умови, яким відповідає щойно вибраний запис. Раніше вибрані записи лишаються на своєму місці.", + whereOneCollection: "Умови діють, коли цільова колекція одна.", + whereNoListFields: + "У колекції «{collection}» немає полів списків для умов. Додайте їх у її налаштуваннях.", + addCondition: "Додати умову", + errors: { + collections: { + required: "Виберіть хоча б одну колекцію.", + limit: "Виберіть не більше п’яти колекцій.", + }, + maxItems: "Введіть ціле число від 1 до {max, number}.", + where: "Умова використовує поле, яке не є полем списку цільової колекції.", + }, + }, + repeater: { + subFields: "Поля кожного елемента", + subFieldsHint: + "Кожен елемент має ці поля в цьому порядку, не більше {max, plural, one {# поля} few {# полів} many {# полів} other {# поля}}. Список не може містити інший список.", + add: "Додати поле елемента", + addTitle: "Додати поле елемента", + editTitle: "Редагувати поле елемента", + dialogDescription: "Підписи й підказки видно в кожному елементі в редакторі.", + addApply: "Додати", + removeSaved: "Видаляється через меню поля", + maxItems: "Не більше", + maxItemsHint: "Скільки елементів може мати запис, до {max, number}. Порожньо: 20.", + itemLabel: "Назва елемента", + itemLabelHint: "Поле, яке називає кожен елемент у редакторі та в схваленнях.", + itemLabelFirst: "Перше поле тексту, вибору чи посилання", + errors: { + subFields: { + required: "Додайте хоча б одне поле для елементів.", + limit: "Список вміщує не більше десяти полів на елемент.", + invalid: "Одне з полів елемента потребує виправлення.", + }, + sub: "Потрібні підпис, вільний ключ, а для посилання — колекція.", + maxItems: "Введіть ціле число від 1 до {max, number}.", + itemLabel: "Виберіть поле тексту, вибору чи посилання.", + }, + }, errors: { label: "Дайте полю підпис.", maxLength: "Введіть ціле число від 1 до {max, number}.", @@ -256,6 +337,17 @@ export const messages: NamespaceMessages<"fields"> = { "Кожен переклад отримає власне значення. Доки його не задано, показується значення оригіналу.", newKey: "Новий ключ", newType: "Новий тип", + appliesTo: "Застосовується до", + wholeField: "Усе поле", + stuckTitle: + "{count, plural, one {# запис більше не перетворюється} few {# записи більше не перетворюються} many {# записів більше не перетворюються} other {# запису більше не перетворюються}}", + stuckRun: + "Їхні значення змінилися під час зміни поля: вибраний запис архівували чи видалили, або заголовок тепер збігається з двома записами. Зміна не може завершитися, доки вони мають ці значення.", + stuckRevert: + "Їхні значення відредагували після зміни, і їх не можна перетворити назад. Скасування не може завершитися, доки вони мають ці значення.", + stuckConfirm: + "Очистіть {count, plural, one {це значення} few {ці # значення} many {ці # значень} other {ці # значення}}, щоб завершити. Їх збережено в snapshot, створеному перед зміною.", + stuckFinish: "Очистити й завершити", check: "Перевірити, що зміниться", checking: "Перевірка записів…", clear: diff --git a/apps/studio/src/i18n/lazy/fields.zh-Hans.ts b/apps/studio/src/i18n/lazy/fields.zh-Hans.ts index 4872f6c9..3f56e071 100644 --- a/apps/studio/src/i18n/lazy/fields.zh-Hans.ts +++ b/apps/studio/src/i18n/lazy/fields.zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 075911861dff4694 +// hash: 48e779b35674360e import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -15,6 +15,8 @@ export const messages: NamespaceMessages<"fields"> = { url: "链接", image: "图片", select: "选项", + reference: "引用", + repeater: "列表", }, editor: { title: "字段", @@ -114,6 +116,27 @@ export const messages: NamespaceMessages<"fields"> = { add: "{count, plural, =0 {添加} other {添加 # 个条目}}", }, }, + publish: { + title: "{count, plural, other {# 个引用将不显示任何内容}}", + description: "发布会照常进行。这些已发布的条目引用了站点不会显示的条目。", + issue: + "{language} · {field}:{reason, select, missing {所选条目已不存在} hidden {所选条目未发布} locale {没有此语言的版本} other {不显示}}", + }, + delete: { + blockedTitle: "{count, plural, other {请先取消发布或编辑这 # 个条目}}", + blocked: + "已发布的页面通过其引用字段显示此条目,而且这是它们能显示的最后一个条目。归档它也没用:它们仍然引用它。请修改它们或取消发布,然后再删除。", + moreReferences: "{count, plural, other {还有 # 个引用。}}", + shownByPublished: "由已发布版本显示于:{languages}", + draftsTitle: "{count, plural, other {# 个草稿引用了此条目}}", + drafts: "此组中已没有其他条目,因此在另选条目之前,这些草稿的字段不会显示任何内容。", + showsNothing: "此字段将不显示任何内容", + localeTitle: "{language}页面将失去此卡片", + locale: "在引用字段中显示此条目的{language}条目,该处将不显示任何内容。", + localeFallback: "在引用字段中显示此条目的{language}条目,将改为显示{fallback}版本。", + where: "{collection} · {field}", + caption: "显示此条目的条目", + }, usedBy: { title: "引用方", description: "字段引用了此条目的条目,已发布的排在前面。", @@ -199,6 +222,53 @@ export const messages: NamespaceMessages<"fields"> = { multipleHint: "条目可以选择多个选项。", add: "添加字段", apply: "应用", + reference: { + collections: "目标集合", + collectionsHint: "可以从这些集合中选择条目,最多 {max, plural, other {# 个集合}}。", + multiple: "多个", + multipleHint: "条目可以选择多个,并自行排序。", + maxItems: "最多", + maxItemsHint: "可选择的数量,最多 {max, number}。留空:20。", + where: "仅限满足以下条件的条目", + whereHint: "新选择的条目需满足的条件。之前选择的条目保持原位。", + whereOneCollection: "仅有一个目标集合时才能设置条件。", + whereNoListFields: "{collection}没有可用于设置条件的列表字段。请在其设置中添加。", + addCondition: "添加条件", + errors: { + collections: { + required: "请至少选择一个集合。", + limit: "最多选择五个集合。", + }, + maxItems: "请输入 1 到 {max, number} 之间的整数。", + where: "某个条件使用的字段不是目标集合的列表字段。", + }, + }, + repeater: { + subFields: "每个项目的字段", + subFieldsHint: + "每个项目都按此顺序包含这些字段,最多 {max, plural, other {# 个字段}}。列表中不能再包含列表。", + add: "添加项目字段", + addTitle: "添加项目字段", + editTitle: "编辑项目字段", + dialogDescription: "标签和帮助文本会显示在编辑器中的每个项目上。", + addApply: "添加", + removeSaved: "通过字段菜单移除", + maxItems: "最多", + maxItemsHint: "一个条目可以有多少个项目,最多 {max, number}。留空:20。", + itemLabel: "项目名称", + itemLabelHint: "在编辑器和审批中为每个项目命名的字段。", + itemLabelFirst: "第一个文本、选项或引用字段", + errors: { + subFields: { + required: "请为项目至少添加一个字段。", + limit: "列表的每个项目最多包含十个字段。", + invalid: "有一个项目字段需要修正。", + }, + sub: "需要标签、未被占用的键;引用字段还需要集合。", + maxItems: "请输入 1 到 {max, number} 之间的整数。", + itemLabel: "请选择文本、选项或引用字段。", + }, + }, errors: { label: "请为字段添加标签。", maxLength: "请输入 1 到 {max, number} 之间的整数。", @@ -235,6 +305,14 @@ export const messages: NamespaceMessages<"fields"> = { toLocalized: "每个译文都会有自己的值。在设置之前,显示原文的值。", newKey: "新键", newType: "新类型", + appliesTo: "应用于", + wholeField: "整个字段", + stuckTitle: "{count, plural, other {# 个条目已无法转换}}", + stuckRun: + "在字段更改期间,它们的值发生了变化:所选条目已被归档或删除,或者某个标题现在匹配到两个条目。只要它们保留这些值,更改就无法完成。", + stuckRevert: "它们的值在更改之后被编辑过,无法转换回去。只要它们保留这些值,撤销就无法完成。", + stuckConfirm: "清除{count, plural, other {这 # 个值}}以完成。它们保存在更改前创建的快照中。", + stuckFinish: "清除并完成", check: "查看变化", checking: "正在检查条目…", clear: "{count, plural, other {清除 # 个无法转换的值}}", diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index 2f1f5466..a1238151 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 7062660c894714b7 +// hash: 491488aa20501dab import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -741,6 +741,24 @@ export const messages: Messages = { "Contentlijstsecties filteren of sorteren nog op dit veld. Pas eerst die pagina's aan.", gates_unsupported: "De live server van deze site ondersteunt dit nog niet. Deploy eerst de site en probeer het daarna opnieuw.", + entry_referenced: + "Gepubliceerde items tonen dit item nog in een verwijzingsveld. Trek eerst hun publicatie in of wijzig ze.", + reference_target_moved: + "Gepubliceerde items tonen dit item uit zijn huidige collectie. Trek eerst hun publicatie in of wijzig ze.", + field_operation_running: + "Een veldwijziging zet waarden om in verwijzingen naar deze collectie. Wacht tot die klaar is.", + unknown_entry: + "Een gekozen item kan niet worden gebruikt: het is verwijderd, gearchiveerd of staat in een andere collectie.", + fields_unsupported: + "Deze server ondersteunt nog geen velden van het type Verwijzing of Lijst.", + translation_group_in_use: + "Er horen nog vertalingen bij dit item. Verplaats of ontkoppel ze voordat je de groep wijzigt.", + too_many_references: + "Een item kan maximaal 100 verwijzingen bevatten, verspreid over al zijn velden.", + field_key_in_use: "Items hebben al waarden onder deze sleutel. Kies een andere sleutel.", + reference_target_unknown: "Een verwijzingsveld noemt een collectie die deze site niet heeft.", + translation_shape_mismatch: + "De vertaalde lijst moet evenveel elementen hebben als die van het origineel. Stuur de hele lijst.", network: "Kan de server niet bereiken. Controleer je verbinding en probeer het opnieuw.", status: "Het verzoek is mislukt (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index e688841c..80054004 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: beccb53cc7a749b5 +// hash: 13c018c5a1a41024 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -726,6 +726,22 @@ export const messages: Messages = { "Sekcje listy treści nadal filtrują lub sortują według tego pola. Najpierw zmień te strony.", gates_unsupported: "Serwer tej witryny na żywo jeszcze tego nie obsługuje. Najpierw wdróż witrynę, a potem spróbuj ponownie.", + entry_referenced: + "Opublikowane wpisy nadal pokazują ten wpis w polu odwołania. Najpierw wycofaj ich publikację lub je zmień.", + reference_target_moved: + "Opublikowane wpisy pokazują ten wpis z jego obecnej kolekcji. Najpierw wycofaj ich publikację lub je zmień.", + field_operation_running: + "Zmiana pola zamienia wartości na odwołania do tej kolekcji. Poczekaj, aż się zakończy.", + unknown_entry: + "Nie można użyć wybranego wpisu: został usunięty, zarchiwizowany lub jest w innej kolekcji.", + fields_unsupported: "Ten serwer nie obsługuje jeszcze pól odwołań ani list.", + translation_group_in_use: + "Do tego wpisu nadal należą tłumaczenia. Przenieś je lub odłącz, zanim zmienisz jego grupę.", + too_many_references: "Wpis może mieć najwyżej 100 odwołań we wszystkich polach.", + field_key_in_use: "Wpisy mają już wartości pod tym kluczem. Wybierz inny klucz.", + reference_target_unknown: "Pole odwołania wskazuje kolekcję, której ta witryna nie ma.", + translation_shape_mismatch: + "Przetłumaczona lista musi mieć tyle elementów co oryginał. Wyślij całą listę.", network: "Brak połączenia z serwerem. Sprawdź połączenie i spróbuj ponownie.", status: "Żądanie nie powiodło się (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index 28b69d96..5ac35f96 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 95d49b5f53cc4a65 +// hash: aec92b0aecbc8e7f import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -732,6 +732,24 @@ export const messages: Messages = { "Seções de lista de conteúdo ainda filtram ou ordenam por este campo. Altere essas páginas primeiro.", gates_unsupported: "O servidor no ar deste site ainda não oferece suporte a isso. Implante o site primeiro e tente de novo.", + entry_referenced: + "Entradas publicadas ainda mostram esta entrada em um campo de referência. Despublique ou altere essas entradas primeiro.", + reference_target_moved: + "Entradas publicadas mostram esta entrada a partir da coleção atual dela. Despublique ou altere essas entradas primeiro.", + field_operation_running: + "Uma alteração de campo está transformando valores em referências a esta coleção. Aguarde até ela terminar.", + unknown_entry: + "Uma entrada escolhida não pode ser usada: ela foi excluída, arquivada ou está em outra coleção.", + fields_unsupported: "Este servidor ainda não aceita campos de referência ou de lista.", + translation_group_in_use: + "Ainda há traduções vinculadas a esta entrada. Mova-as ou desvincule-as antes de mudar o grupo dela.", + too_many_references: + "Uma entrada pode ter no máximo 100 referências somando todos os campos.", + field_key_in_use: "Já há entradas com valores nesta chave. Escolha outra chave.", + reference_target_unknown: + "Um campo de referência aponta para uma coleção que este site não tem.", + translation_shape_mismatch: + "A lista traduzida precisa ter tantos itens quanto a do original. Envie a lista inteira.", network: "Não foi possível alcançar o servidor. Verifique sua conexão e tente de novo.", status: "A requisição falhou (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index 2691f4c4..d02d2796 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 0c0bafbd64bc3732 +// hash: 00a320d518a15650 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -738,6 +738,22 @@ export const messages: Messages = { "Разделы «Список контента» всё ещё фильтруют или сортируют по этому полю. Сначала измените эти страницы.", gates_unsupported: "Рабочий сервер этого сайта пока это не поддерживает. Сначала разверните сайт, затем повторите попытку.", + entry_referenced: + "Опубликованные записи всё ещё показывают эту запись в поле-ссылке. Сначала снимите их с публикации или измените.", + reference_target_moved: + "Опубликованные записи показывают эту запись из её текущей коллекции. Сначала снимите их с публикации или измените.", + field_operation_running: + "Изменение поля превращает значения в ссылки на эту коллекцию. Дождитесь его завершения.", + unknown_entry: + "Выбранную запись нельзя использовать: она удалена, в архиве или находится в другой коллекции.", + fields_unsupported: "Этот сервер пока не поддерживает поля-ссылки и поля-списки.", + translation_group_in_use: + "К этой записи всё ещё относятся переводы. Перенесите или отвяжите их, прежде чем менять её группу.", + too_many_references: "У записи может быть не больше 100 ссылок во всех полях.", + field_key_in_use: "У записей уже есть значения под этим ключом. Выберите другой ключ.", + reference_target_unknown: "Поле-ссылка указывает на коллекцию, которой нет на этом сайте.", + translation_shape_mismatch: + "В переведённом списке должно быть столько же элементов, сколько в оригинале. Отправьте весь список.", network: "Не удаётся связаться с сервером. Проверьте подключение и повторите попытку.", status: "Запрос не выполнен (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 0336d357..11129861 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8153094b42fee39f +// hash: 5730e68855f6317b import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -721,6 +721,22 @@ export const messages: Messages = { "İçerik listesi bölümleri bu alana göre hâlâ filtreliyor ya da sıralıyor. Önce o sayfaları değiştir.", gates_unsupported: "Bu sitenin canlı sunucusu bunu henüz desteklemiyor. Önce siteyi dağıt, sonra tekrar dene.", + entry_referenced: + "Yayımlanmış kayıtlar bu kaydı hâlâ bir referans alanında gösteriyor. Önce onları yayından kaldır ya da değiştir.", + reference_target_moved: + "Yayımlanmış kayıtlar bu kaydı şu anki koleksiyonundan gösteriyor. Önce onları yayından kaldır ya da değiştir.", + field_operation_running: + "Bir alan değişikliği, değerleri bu koleksiyona giden referanslara dönüştürüyor. Bitmesini bekle.", + unknown_entry: + "Seçilen bir kayıt kullanılamıyor: silinmiş, arşivlenmiş ya da başka bir koleksiyonda.", + fields_unsupported: "Bu sunucu henüz referans ve liste alanlarını desteklemiyor.", + translation_group_in_use: + "Bu kayda hâlâ çeviriler bağlı. Grubunu değiştirmeden önce onları taşı ya da bağlantılarını kaldır.", + too_many_references: "Bir kayıt, tüm alanlarında toplam en fazla 100 referans tutabilir.", + field_key_in_use: "Bu anahtar altında zaten değer tutan kayıtlar var. Başka bir anahtar seç.", + reference_target_unknown: "Bir referans alanı, bu sitede olmayan bir koleksiyonu gösteriyor.", + translation_shape_mismatch: + "Çevrilen liste, orijinaldeki kadar öğe içermeli. Listenin tamamını gönder.", network: "Sunucuya ulaşılamıyor. Bağlantını kontrol edip tekrar dene.", status: "İstek başarısız oldu (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index 31bc6e08..24a12c55 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 98d0c3037d5bd41f +// hash: ceff72db1cc5513e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -730,6 +730,22 @@ export const messages: Messages = { "Розділи «Список вмісту» досі фільтрують або сортують за цим полем. Спершу змініть ці сторінки.", gates_unsupported: "Робочий сервер цього сайту ще цього не підтримує. Спершу розгорніть сайт, потім спробуйте ще раз.", + entry_referenced: + "Опубліковані записи досі показують цей запис у полі посилання. Спершу зніміть їх з публікації або змініть.", + reference_target_moved: + "Опубліковані записи показують цей запис з його поточної колекції. Спершу зніміть їх з публікації або змініть.", + field_operation_running: + "Зміна поля перетворює значення на посилання на цю колекцію. Дочекайтеся її завершення.", + unknown_entry: + "Вибраний запис не можна використати: його видалено, архівовано або він в іншій колекції.", + fields_unsupported: "Цей сервер ще не підтримує поля посилань і списків.", + translation_group_in_use: + "До цього запису досі належать переклади. Перенесіть або відв’яжіть їх, перш ніж змінювати його групу.", + too_many_references: "Запис може містити не більше 100 посилань у всіх полях.", + field_key_in_use: "Записи вже мають значення під цим ключем. Виберіть інший ключ.", + reference_target_unknown: "Поле посилання вказує на колекцію, якої на цьому сайті немає.", + translation_shape_mismatch: + "Перекладений список має містити стільки ж елементів, скільки оригінал. Надішліть увесь список.", network: "Не вдається зв’язатися із сервером. Перевірте з’єднання й спробуйте знову.", status: "Запит не вдався (HTTP {status}).", }, diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index bbcc03b5..57241cf6 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4149d141a4eae8ec +// hash: a6acdffd3eac88aa import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -691,6 +691,16 @@ export const messages: Messages = { invalid_expiry: "到期需要此集合的一个日期或日期和时间字段。", list_field_in_use: "仍有内容列表区块按此字段筛选或排序。请先修改那些页面。", gates_unsupported: "此站点的线上服务器尚不支持此功能。请先部署站点,然后再试一次。", + entry_referenced: "已发布的条目仍在引用字段中显示此条目。请先取消发布或修改它们。", + reference_target_moved: "已发布的条目从此条目当前所在的集合显示它。请先取消发布或修改它们。", + field_operation_running: "有字段更改正在把值转换为对此集合的引用。请等待其完成。", + unknown_entry: "无法使用所选条目:它已被删除、归档,或位于其他集合中。", + fields_unsupported: "此服务器暂不支持引用字段或列表字段。", + translation_group_in_use: "此条目仍有归属于它的译文。请先移动它们或解除关联,再更改其分组。", + too_many_references: "一个条目的所有字段中最多可包含 100 个引用。", + field_key_in_use: "已有条目在此键下保存了值。请换一个键。", + reference_target_unknown: "某个引用字段指向了此站点没有的集合。", + translation_shape_mismatch: "翻译后的列表项目数必须与原文相同。请发送完整列表。", network: "无法连接服务器。请检查网络连接后重试。", status: "请求失败(HTTP {status})。", }, diff --git a/apps/studio/src/lib/api/collections.ts b/apps/studio/src/lib/api/collections.ts index fb561d54..ff907f81 100644 --- a/apps/studio/src/lib/api/collections.ts +++ b/apps/studio/src/lib/api/collections.ts @@ -1,5 +1,5 @@ /** RFC-0040: the site's collection registry (declared, inferred from entries, and `posts`). */ -import type { CollectionSettings, Site } from "@cloudhal/core-contracts"; +import type { CollectionSettings, Site, SubFieldDefinition } from "@cloudhal/core-contracts"; import type { FieldOperationResult, FieldOperationStatus, @@ -12,17 +12,26 @@ export type { CollectionInfo, FieldOperationResult, FieldOperationStatus, FieldR /** RFC-0041 §6: what Studio asks of a field operation (the API's body without `collection`). */ export type FieldOperationRequest = - | { op: "rename_field"; from: string; to: string } + | { op: "rename_field"; from: string; to: string; sub?: string } | { op: "change_field_type"; key: string; type: string; + /** RFC-0045: a repeater's sub-field the change applies to. */ + sub?: string; options?: unknown[]; multiple?: boolean; + /** RFC-0045: a reference's target collections (required to a reference). */ + collections?: string[]; + /** RFC-0045: a multiple reference's or a repeater's item cap. */ + maxItems?: number; + /** RFC-0045: a repeater's sub-fields (required to a repeater) and the one naming items. */ + subFields?: SubFieldDefinition[]; + itemLabel?: string; onInvalid?: "clear"; } | { op: "set_field_localized"; key: string; localized: boolean } - | { op: "remove_field"; key: string }; + | { op: "remove_field"; key: string; sub?: string }; const base = (siteId: string) => `/api/sites/${encodeURIComponent(siteId)}/collections`; @@ -50,20 +59,21 @@ export const collectionsApi = { ...json(body), }), /** Resumes an operation that answered `done: false` (or was interrupted). */ - continueField: (siteId: string, key: string, operationId: string) => + /** RFC-0045 §7: `clear` drops the values that stopped converting, so the run can finish. */ + continueField: (siteId: string, key: string, operationId: string, clear = false) => request( `${base(siteId)}/${encodeURIComponent(key)}/fields/operations?continue=${encodeURIComponent(operationId)}`, - { method: "POST", ...json({}) }, + { method: "POST", ...json(clear ? { onInvalid: "clear" } : {}) }, ), fieldStatus: (siteId: string, key: string, operationId: string) => request( `${base(siteId)}/${encodeURIComponent(key)}/fields/operations/${encodeURIComponent(operationId)}`, ), /** Restores the values before the operation; call again while `done: false`. */ - revertField: (siteId: string, key: string, operationId: string) => + revertField: (siteId: string, key: string, operationId: string, clear = false) => request( `${base(siteId)}/${encodeURIComponent(key)}/fields/operations/${encodeURIComponent(operationId)}/revert`, - { method: "POST", ...json({}) }, + { method: "POST", ...json(clear ? { onInvalid: "clear" } : {}) }, ), /** Undeclares a collection without entries (409 `collection_in_use` otherwise). */ remove: (site: Pick, key: string) => diff --git a/apps/studio/src/lib/api/references.ts b/apps/studio/src/lib/api/references.ts index eda3a882..9c4b35eb 100644 --- a/apps/studio/src/lib/api/references.ts +++ b/apps/studio/src/lib/api/references.ts @@ -41,6 +41,26 @@ export interface ReferrersPage { next?: number; } +/** Why a published entry's reference will show nothing (`referenceIssues` in core-ts). */ +export interface ReferenceIssue { + entryId: string; + /** The holder's title. */ + title: string; + collection: string; + locale: string; + /** `fields/venue`, `fields/speakers/1/person`. */ + path: string; + groupId: string; + reason: "missing" | "hidden" | "locale"; +} + +/** The store's `entry_referenced` details: up to 20 published referrers and the total. */ +export interface ReferencedDetails { + group: string; + referrers: { id: string; title: string; collection: string; field: string }[]; + total: number; +} + export const referencesApi = { pick: ( siteId: string, @@ -59,6 +79,17 @@ export const referencesApi = { request<{ groups: Record }>( `/api/sites/${siteId}/entries/groups${query({ ids: ids.join(",") })}`, ), + /** What publishing the working copy now would show as nothing: the first 50 and the total. */ + issues: (siteId: string) => + request<{ total: number; issues: ReferenceIssue[] }>( + `/api/sites/${siteId}/entries/reference-issues`, + ), + /** Asks the store whether `entry` can be deleted; throws its refusal (`entry_referenced`). */ + deleteCheck: (entry: { siteId: string; id: string; rev: number }) => + request<{ dryRun: true }>( + `/api/sites/${entry.siteId}/entries/${entry.id}${query({ rev: entry.rev, dryRun: 1 })}`, + { method: "DELETE" }, + ), /** The referrers of the entry's group, published holders first. */ referrers: (siteId: string, entryId: string, page: { limit?: number; offset?: number } = {}) => request( diff --git a/apps/studio/src/lib/api/team.ts b/apps/studio/src/lib/api/team.ts index f262b550..f8cc965b 100644 --- a/apps/studio/src/lib/api/team.ts +++ b/apps/studio/src/lib/api/team.ts @@ -40,6 +40,8 @@ export interface InstanceInfo { ai?: AiTransportKind; /** RFC-0036 §5: set only on a playground session (try.cloudhal.com). */ playground?: PlaygroundInfo; + /** RFC-0045: the server takes reference and repeater fields. Older servers omit it: off. */ + referenceFields?: boolean; }; /** The server's agent-mode rules (ADR-0015). Older servers omit it; Studio then uses its own copy. */ agentModes?: AgentModesInfo; @@ -232,7 +234,13 @@ export type SignUpPolicy = InstanceInfo["signUp"]; /** The on/off features (`analytics` is an object of its own). */ export type InstanceFeature = Exclude< keyof InstanceInfo["features"], - "analytics" | "comments" | "schedules" | "ai" | "playground" | "mcpOauthClients" + | "analytics" + | "comments" + | "schedules" + | "ai" + | "playground" + | "mcpOauthClients" + | "referenceFields" >; export const teamApi = { diff --git a/apps/studio/src/lib/field-form-stage4.ts b/apps/studio/src/lib/field-form-stage4.ts new file mode 100644 index 00000000..d56abb92 --- /dev/null +++ b/apps/studio/src/lib/field-form-stage4.ts @@ -0,0 +1,213 @@ +/** + * The fields builder's reference and repeater parts (RFC-0045 §5): target collections, "Several", + * the maximum and conditions of a reference; a repeater's sub-fields (each a `FieldForm` of a + * sub-field type, with a stable id so per-row state never follows an index), the maximum and the + * item label. Pure; words come from the caller. Loads with the fields UI only (`LAZY_ONLY`). + */ +import type { FieldCondition, FieldDefinition, SubFieldDefinition } from "@cloudhal/core-contracts"; +import type { SubFieldType } from "@cloudhal/core-ts/fields"; +import type { FieldForm } from "./field-form.ts"; +import { type KeyProblem, keyProblem } from "./fields.ts"; + +/** The types a repeater's sub-field can have (no date and time, no repeater). */ +export const SUB_TYPES: readonly SubFieldType[] = [ + "text", + "richText", + "number", + "boolean", + "date", + "url", + "image", + "select", + "reference", +]; + +/** At most this many target collections, conditions and sub-fields (the contract's). */ +export const STAGE4_LIMITS = { + collections: 5, + conditions: 4, + subFields: 10, + maxItems: 50, +} as const; + +/** One sub-field in the builder: its form, whether it is saved, and an id for its row. */ +export interface SubFieldForm { + id: string; + /** Saved in the repeater: its key and type change through operations. */ + saved: boolean; + form: FieldForm; +} + +let next = 0; +/** A row id that never repeats in this Studio session. */ +export const subId = () => `sub-${next++}`; + +/** The item label's candidates: text, select and reference sub-fields. */ +export const namesItems = (f: Pick) => + f.type === "text" || f.type === "select" || f.type === "reference"; + +export interface Stage4Problems { + collections?: "required" | "limit"; + maxItems?: "range"; + /** A condition on a field that is no listing field of the one target collection. */ + where?: "field"; + subFields?: "required" | "limit" | "invalid"; + itemLabel?: "type"; + /** The first sub-field's problem, by its id (its row shows it). */ + sub?: { id: string; key?: KeyProblem; label?: "required"; collections?: "required" }; +} + +const maxItemsOk = (s: string) => { + if (!s.trim()) return true; + const n = Number(s); + return Number.isInteger(n) && n >= 1 && n <= STAGE4_LIMITS.maxItems; +}; + +/** What keeps a reference or repeater form from saving. */ +export function stage4Problems( + form: FieldForm, + defaultLocale: string, + /** A collection's listing field keys (a reference's conditions must use them). */ + listFields?: (collection: string) => readonly string[], +): Stage4Problems { + const out: Stage4Problems = {}; + if (form.type === "reference") { + if (form.collections.length === 0) out.collections = "required"; + else if (form.collections.length > STAGE4_LIMITS.collections) out.collections = "limit"; + if (form.multiple && !maxItemsOk(form.maxItems)) out.maxItems = "range"; + const only = form.collections.length === 1 ? form.collections[0] : undefined; + const fits = only && listFields ? new Set(listFields(only)) : undefined; + if (fits && form.where.some((w) => !fits.has(w.field))) out.where = "field"; + } + if (form.type !== "repeater") return out; + if (!maxItemsOk(form.maxItems)) out.maxItems = "range"; + if (form.subFields.length === 0) out.subFields = "required"; + else if (form.subFields.length > STAGE4_LIMITS.subFields) out.subFields = "limit"; + for (const s of form.subFields) { + const taken = form.subFields.filter((o) => o.id !== s.id).map((o) => o.form.key); + const key = s.saved ? undefined : keyProblem(s.form.key, taken); + const label = s.form.labels[defaultLocale]?.label.trim() ? undefined : ("required" as const); + const collections = + s.form.type === "reference" && s.form.collections.length === 0 + ? ("required" as const) + : undefined; + if (key || label || collections) { + out.subFields ??= "invalid"; + out.sub = { id: s.id, ...(key ? { key } : {}), ...(label ? { label } : {}) }; + if (collections) out.sub.collections = collections; + break; + } + } + const named = form.subFields.find((s) => s.form.key === form.itemLabel); + if (form.itemLabel && (!named || !namesItems(named.form))) out.itemLabel = "type"; + return out; +} + +const parseNum = (s: string) => (s.trim() === "" ? undefined : Number(s)); + +/** + * A reference definition from the form: collections, `multiple` with its maximum, and conditions + * (only with one target collection; a condition without a value is left out). + */ +export function referenceDefinition( + form: FieldForm, + base: Pick, +): FieldDefinition { + const maxItems = parseNum(form.maxItems); + const where = form.collections.length === 1 ? form.where.filter(conditionReady) : []; + return { + ...base, + type: "reference", + ...(form.requiredToPublish ? { requiredToPublish: true } : {}), + collections: form.collections as [string, ...string[]], + ...(form.multiple ? { multiple: true } : {}), + ...(form.multiple && maxItems !== undefined ? { maxItems } : {}), + ...(where.length ? { where: where as [FieldCondition, ...FieldCondition[]] } : {}), + } as FieldDefinition; +} + +/** A condition is sent once it says what to compare with (or needs nothing). */ +const conditionReady = (c: FieldCondition) => + c.op === "set" || c.op === "unset" || (c.op === "in" ? (c.values?.length ?? 0) > 0 : !!c.value); + +/** A repeater definition from the form; each sub-field through `subDefinition`. */ +export function repeaterDefinition( + form: FieldForm, + base: Pick, + definitionOf: (form: FieldForm) => FieldDefinition, +): FieldDefinition { + const maxItems = parseNum(form.maxItems); + const fields = form.subFields.map((s) => subDefinition(definitionOf(s.form))); + return { + ...base, + type: "repeater", + ...(form.requiredToPublish ? { requiredToPublish: true } : {}), + fields: fields as [SubFieldDefinition, ...SubFieldDefinition[]], + ...(maxItems !== undefined ? { maxItems } : {}), + ...(form.itemLabel ? { itemLabel: form.itemLabel } : {}), + } as FieldDefinition; +} + +/** A field definition as a sub-field: no `localized`, `searchable`, `multiple` or reference extras. */ +export function subDefinition(def: FieldDefinition): SubFieldDefinition { + const { + localized: _l, + migration: _m, + ...rest + } = def as FieldDefinition & { searchable?: boolean; multiple?: boolean }; + const out: Record = { ...rest }; + for (const k of ["searchable", "multiple", "maxItems", "where"]) delete out[k]; + return out as unknown as SubFieldDefinition; +} + +/** A sub-field as a field definition, so the builder's form functions read it. */ +export const subAsDefinition = (sub: SubFieldDefinition): FieldDefinition => + ({ ...sub, localized: false }) as FieldDefinition; + +/** What `formOf` adds for a reference or repeater (the repeater's sub-field forms through `formOf`). */ +export function stage4FormOf( + def: FieldDefinition, + formOf: (def: FieldDefinition) => FieldForm, +): Pick { + const num = (n: number | undefined) => (n === undefined ? "" : String(n)); + if (def.type === "reference") + return { + collections: [...def.collections], + multiple: def.multiple === true, + maxItems: num(def.maxItems), + where: [...(def.where ?? [])], + subFields: [], + itemLabel: "", + }; + if (def.type === "repeater") + return { + collections: [], + multiple: false, + maxItems: num(def.maxItems), + where: [], + subFields: def.fields.map((s) => ({ + id: subId(), + saved: true, + form: formOf(subAsDefinition(s)), + })), + itemLabel: def.itemLabel ?? "", + }; + return { + collections: [], + multiple: false, + maxItems: "", + where: [], + subFields: [], + itemLabel: "", + }; +} + +/** The sub-field list with `id` moved one place up (`-1`) or down (`1`). */ +export function moveSub(list: readonly SubFieldForm[], id: string, by: -1 | 1): SubFieldForm[] { + const i = list.findIndex((s) => s.id === id); + const j = i + by; + if (i < 0 || j < 0 || j >= list.length) return [...list]; + const out = [...list]; + [out[i], out[j]] = [out[j] as SubFieldForm, out[i] as SubFieldForm]; + return out; +} diff --git a/apps/studio/src/lib/field-form.ts b/apps/studio/src/lib/field-form.ts index e14017ff..14734716 100644 --- a/apps/studio/src/lib/field-form.ts +++ b/apps/studio/src/lib/field-form.ts @@ -3,9 +3,17 @@ * contract's shape, and what is wrong with it. Pure; words come from the caller. Loads with the * fields UI only (`bundle-budget.ts` `LAZY_ONLY`). */ -import type { FieldDefinition } from "@cloudhal/core-contracts"; +import type { FieldCondition, FieldDefinition } from "@cloudhal/core-contracts"; import { FIELD_LIMITS } from "@cloudhal/core-ts/fields"; import { trimChar } from "@cloudhal/core-ts/names"; +import { + referenceDefinition, + repeaterDefinition, + type Stage4Problems, + type SubFieldForm, + stage4FormOf, + stage4Problems, +} from "./field-form-stage4.ts"; import { type FieldType, fieldKeyFrom, @@ -38,7 +46,15 @@ export interface FieldForm { minimum: string; maximum: string; options: OptionForm[]; + /** A select's or (RFC-0045) a reference's "Several". */ multiple: boolean; + /** RFC-0045: a reference's target collections, a list's maximum and a reference's conditions. */ + collections: string[]; + maxItems: string; + where: FieldCondition[]; + /** RFC-0045: a repeater's sub-fields and the one naming its items. */ + subFields: SubFieldForm[]; + itemLabel: string; } /** The site's languages, default first. */ @@ -64,6 +80,11 @@ export function emptyForm(type: FieldType, locales: readonly string[]): FieldFor maximum: "", options: [], multiple: false, + collections: [], + maxItems: "", + where: [], + subFields: [], + itemLabel: "", }; } @@ -95,6 +116,9 @@ export function formOf(def: FieldDefinition, locales: readonly string[]): FieldF })) : [], multiple: def.type === "select" && def.multiple === true, + ...(def.type === "reference" || def.type === "repeater" + ? stage4FormOf(def, (d) => formOf(d, locales)) + : {}), }; } @@ -136,7 +160,7 @@ export function withOptionLabel( return { ...form, options }; } -export interface FormProblems { +export interface FormProblems extends Stage4Problems { label?: "required"; key?: KeyProblem; maxLength?: "range"; @@ -149,7 +173,13 @@ const parseNum = (s: string) => (s.trim() === "" ? undefined : Number(s)); /** What keeps the form from saving (keys checked only while the field is new). */ export function formProblems( form: FieldForm, - ctx: { defaultLocale: string; taken: readonly string[]; isNew: boolean }, + ctx: { + defaultLocale: string; + taken: readonly string[]; + isNew: boolean; + /** RFC-0045: a collection's listing field keys, for a reference's conditions. */ + listFields?: (collection: string) => readonly string[]; + }, ): FormProblems { const out: FormProblems = {}; if (!form.labels[ctx.defaultLocale]?.label.trim()) out.label = "required"; @@ -171,6 +201,7 @@ export function formProblems( out.bounds = "number"; else if (min !== undefined && max !== undefined && min > max) out.bounds = "order"; } + Object.assign(out, stage4Problems(form, ctx.defaultLocale, ctx.listFields)); if (form.type === "select") { const values = form.options.map((o) => o.value); if (form.options.length === 0) out.options = "required"; @@ -240,6 +271,10 @@ export function definitionOf(form: FieldForm): FieldDefinition { ...(form.multiple ? { multiple: true } : {}), } as FieldDefinition; } + case "reference": + return referenceDefinition(form, base); + case "repeater": + return repeaterDefinition(form, base, definitionOf); default: return { ...base, type: form.type, ...required } as FieldDefinition; } diff --git a/apps/studio/src/lib/field-references.ts b/apps/studio/src/lib/field-references.ts index 420c115e..1ee45288 100644 --- a/apps/studio/src/lib/field-references.ts +++ b/apps/studio/src/lib/field-references.ts @@ -93,3 +93,17 @@ export function referrerFieldWords( const sub = def?.type === "repeater" ? def.fields.find((s) => s.key === subKey) : undefined; return { field, sub: sub ? fieldLabel(sub, locale, defaultLocale) : subKey }; } + +/** The collections a referrer's field (`venue`, `speakers.person`) allows, from its definition. */ +export function fieldCollections( + site: Site | undefined, + collection: string, + path: string, +): readonly string[] { + const [key = path, subKey] = path.split("."); + const def = site?.collections?.[collection]?.fields?.find((d) => d.key === key); + if (def?.type === "reference") return def.collections; + if (def?.type !== "repeater") return []; + const sub = def.fields.find((s) => s.key === subKey); + return sub?.type === "reference" ? sub.collections : []; +} diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx index e8630e51..c6ecdb0c 100644 --- a/apps/studio/src/routes/ArticleEditorPage.tsx +++ b/apps/studio/src/routes/ArticleEditorPage.tsx @@ -3,7 +3,6 @@ import { collectionPermalink, permalinkPrefix } from "@cloudhal/core-ts/permalin import { ApprovalDialog, Button, - DeleteResource, DropdownMenu, Field, type ImagePreviewStatus, @@ -29,7 +28,6 @@ import { BodyEditor } from "../components/BodyEditor.tsx"; import { BodyProposals } from "../components/BodyProposals.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { DraftEditorPreview } from "../components/DraftPreview.tsx"; -import { deleteResourceProps } from "../components/deleteLabels.ts"; import { DangerZoneCard, type DetailRow, @@ -44,13 +42,14 @@ import { ScheduleAside, ScheduleLock, } from "../components/EditorSchedule.tsx"; +import { EntryDeleteDialog } from "../components/EntryDeleteDialog.tsx"; import { EntryFieldCards } from "../components/EntryFieldCards.tsx"; import { EntryPath } from "../components/EntryPath.tsx"; import { EntryStatus } from "../components/EntryStatus.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; import { ViewOnlyBanner } from "../components/Gating.tsx"; import { EntryAddress, ViewLiveButton } from "../components/LiveAddress.tsx"; -import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx"; +import { useMenuReferences } from "../components/Menus.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { ArticleSeoPreviewCard, CountedHint, CoverImageCard } from "../components/Seo.tsx"; import { StatusActions } from "../components/StatusActions.tsx"; @@ -575,12 +574,12 @@ export function ArticleEditorPage() { setPublishOpen(false); }} /> - } + siteId={siteId} + entry={draft} + menuRefs={menuRefs.data} isDeleting={remove.isPending} onDelete={async () => { await remove.mutateAsync(draft); diff --git a/apps/studio/src/routes/ArticlesPage.tsx b/apps/studio/src/routes/ArticlesPage.tsx index 476b1c6a..8bf8dc90 100644 --- a/apps/studio/src/routes/ArticlesPage.tsx +++ b/apps/studio/src/routes/ArticlesPage.tsx @@ -2,7 +2,6 @@ import type { ContentEntry, FieldOrder, Site, Taxonomy } from "@cloudhal/core-co import { Badge, Button, - DeleteResource, Empty, ImagePreview, LayerCard, @@ -31,7 +30,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { lazy, type ReactNode, Suspense, useState } from "react"; import { CollectionActions } from "../components/CollectionActions.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; -import { deleteResourceProps } from "../components/deleteLabels.ts"; +import { EntryDeleteDialog } from "../components/EntryDeleteDialog.tsx"; import { EntryPath } from "../components/EntryPath.tsx"; import { EntryStatus } from "../components/EntryStatus.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; @@ -43,7 +42,7 @@ import { useListViews, } from "../components/ListViews.tsx"; import { EntryLiveItems } from "../components/LiveRowItems.tsx"; -import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx"; +import { useMenuReferences } from "../components/Menus.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { RowMenu } from "../components/RowMenu.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; @@ -283,12 +282,12 @@ export function ArticlesPage() { )} - !o && setToDelete(undefined)} - {...deleteResourceProps(t, t("editor.article"))} - resourceName={toDelete?.slug ?? ""} - notice={} + siteId={siteId} + entry={toDelete} + menuRefs={deleteRefs.data} isDeleting={remove.isPending} onDelete={async () => { if (toDelete) await remove.mutateAsync(toDelete); diff --git a/apps/studio/test/design-rules.test.ts b/apps/studio/test/design-rules.test.ts index 67c1a49f..6522cc1c 100644 --- a/apps/studio/test/design-rules.test.ts +++ b/apps/studio/test/design-rules.test.ts @@ -100,10 +100,12 @@ const REQUIRED_IMPORTS: Record = { "TableSkeleton", "Empty", "Pagination", - "DeleteResource", + // Entries delete through EntryDeleteDialog: DeleteResource with the menu and reference notices. + "EntryDeleteDialog", "RowMenu", "RelativeTime", ], + "components/EntryDeleteDialog.tsx": ["DeleteResource", "MenuDeleteNotice", "deleteResourceProps"], "routes/ArticleEditorPage.tsx": [ "PageLayout", "SiteCrumbs", @@ -116,7 +118,7 @@ const REQUIRED_IMPORTS: Record = { "InputArea", "ArticleTermsCard", "ApprovalDialog", - "DeleteResource", + "EntryDeleteDialog", "SkeletonLine", ], "routes/PageEditorPage.tsx": [ @@ -215,10 +217,10 @@ const REQUIRED_IMPORTS: Record = { "components/MenuSaveDialog.tsx": ["LayerDialog", "LayerDialog.Actions.Primary", "InsetPanel"], "components/MenuDialogs.tsx": ["LayerDialog", "Radio", "Checkbox", "InsetPanel", "SkeletonLine"], "routes/PageEditorPage.tsx#menus": ["UsedInMenusCard", "MenuDeleteNotice"], - "routes/ArticleEditorPage.tsx#menus": ["UsedInMenusCard", "MenuDeleteNotice"], + "routes/ArticleEditorPage.tsx#menus": ["UsedInMenusCard", "EntryDeleteDialog"], "routes/TermEditorPage.tsx#menus": ["UsedInMenusCard"], "routes/PagesPage.tsx#menus": ["MenuDeleteNotice"], - "routes/ArticlesPage.tsx#menus": ["MenuDeleteNotice"], + "routes/ArticlesPage.tsx#menus": ["EntryDeleteDialog"], "routes/ActivityPage.tsx": [ "PageLayout", "SiteCrumbs", @@ -1345,15 +1347,15 @@ describe("design rules (DESIGN.md)", () => { }); it("deleting a page or article names the menu items it hides (§2t, RFC-0003 invariant 10)", () => { - for (const file of [ - "routes/PageEditorPage.tsx", - "routes/ArticleEditorPage.tsx", - "routes/PagesPage.tsx", - "routes/ArticlesPage.tsx", - ]) { + for (const file of ["routes/PageEditorPage.tsx", "routes/PagesPage.tsx"]) { const text = screenText(file); expect(text, file).toMatch(/ f.file === "components/EntryDeleteDialog.tsx")?.text; + expect(dialog).toMatch(/ { diff --git a/apps/studio/test/fields-builder-references.test.tsx b/apps/studio/test/fields-builder-references.test.tsx new file mode 100644 index 00000000..ba5b8c99 --- /dev/null +++ b/apps/studio/test/fields-builder-references.test.tsx @@ -0,0 +1,869 @@ +/** + * RFC-0045 §5 (rollout step 6b): the fields builder's reference and repeater forms behind the + * server's capability, sub-field operations and a stuck run's cleared finish, the delete dialog's + * reference rules, and the publish dialog's warnings. + */ +import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EntryDeleteDialog } from "../src/components/EntryDeleteDialog.tsx"; +import { FieldsSection } from "../src/components/fields/FieldsSection.tsx"; +import { + OperationInputs, + requestOf, + targetProblems, +} from "../src/components/fields/OperationInputs.tsx"; +import { PublishWarnings } from "../src/components/fields/PublishWarnings.tsx"; +import { ApiRequestError } from "../src/lib/api/http.ts"; +import { emptyForm, formOf } from "../src/lib/field-form.ts"; +import { stage4Problems } from "../src/lib/field-form-stage4.ts"; +import { bodyOf, on, serve, testQueryClient, wrap } from "./roles-fixtures.tsx"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const L = (label: string) => ({ en: { label } }); +const VENUE: FieldDefinition = { + key: "venue", + type: "reference", + labels: L("Venue"), + localized: false, + collections: ["places"], +}; +const SPEAKERS: FieldDefinition = { + key: "speakers", + type: "repeater", + labels: L("Speakers"), + localized: false, + fields: [{ key: "name", type: "text", labels: L("Name") }], +}; +const SITE = { + schemaVersion: 1, + id: "site_1", + projectId: "proj_1", + name: "Site", + slug: "site", + defaultLocale: "en", + locales: ["en", "tr"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + localization: { fallback: "default-with-notice" }, + collections: { + events: { fields: [VENUE, SPEAKERS] }, + places: { + labels: { en: { singular: "Place", plural: "Places" } }, + fields: [ + { + key: "kind", + type: "select", + labels: L("Kind"), + localized: false, + options: [{ value: "hall", labels: { en: "Hall" } }], + }, + ], + listFields: ["kind"], + }, + people: { labels: { en: { singular: "Person", plural: "People" } } }, + }, + rev: 7, + createdAt: "2026-11-01T10:00:00Z", + updatedAt: "2026-11-01T10:00:00Z", +} as unknown as Site; + +const REGISTRY = ["events", "places", "people"].map((key) => ({ key, counts: {} })); +const instance = (referenceFields: boolean) => + on("GET", "/api/instance", { status: 200, body: { features: { referenceFields } } }); +const registry = on("GET", "/api/sites/site_1/collections", { status: 200, body: REGISTRY }); +const put = (init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + return { + status: 200, + body: { dryRun: false, site: { ...SITE, rev: 8, collections: { events: body.settings } } }, + }; +}; + +function Section() { + return ( + + ); +} + +async function typeOptions() { + fireEvent.click(await screen.findByTestId("add-field")); + const dialog = await screen.findByTestId("field-form"); + fireEvent.click(within(dialog).getByRole("combobox", { name: "Type" })); + return (await screen.findAllByRole("option")).map((o) => o.textContent); +} + +describe("Fields builder › the capability gate", () => { + it("offers reference and list only when the server takes them", async () => { + serve(instance(false), registry); + render(wrap(
)); + expect(await typeOptions()).not.toContain("Reference"); + cleanup(); + serve(instance(true), registry); + render(wrap(
)); + expect(await typeOptions()).toEqual(expect.arrayContaining(["Reference", "List"])); + }); +}); + +describe("Fields builder › a reference", () => { + it("edits the target collections and conditions, and saves them", async () => { + const fetch = serve( + instance(true), + registry, + on("PUT", "/api/sites/site_1/collections/events", put), + ); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Venue" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const form = await screen.findByTestId("reference-fields"); + const targets = await within(form).findByRole("group", { name: "Target collections" }); + expect(within(targets).getByRole("checkbox", { name: "Places" })).toBeChecked(); + // A saved field changes "Several" through "Change type…". + expect(within(form).getByRole("switch", { name: "Several" })).toBeDisabled(); + fireEvent.click(within(form).getByRole("button", { name: "Add condition" })); + expect( + within(form).getByTestId("reference-where").querySelector("[data-condition=kind]"), + ).not.toBeNull(); + fireEvent.click(screen.getByTestId("field-apply")); + fireEvent.click(await screen.findByTestId("save-fields")); + await waitFor(() => + expect( + bodyOf(fetch, "PUT", "/api/sites/site_1/collections/events")?.settings.fields[0], + ).toEqual({ + ...VENUE, + where: [{ field: "kind", op: "eq", value: "hall" }], + }), + ); + }); + + it("refuses a reference without a target collection", async () => { + serve(instance(true), registry); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Venue" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const targets = await screen.findByRole("group", { name: "Target collections" }); + fireEvent.click(within(targets).getByText("Places")); + fireEvent.click(screen.getByTestId("field-apply")); + expect(await screen.findByText("Choose at least one collection.")).toBeInTheDocument(); + }); +}); + +describe("Fields builder › a list (repeater)", () => { + it("adds an item field in its own dialog, moves it, and saves the list", async () => { + const fetch = serve( + instance(true), + registry, + on("PUT", "/api/sites/site_1/collections/events", put), + ); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Speakers" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const list = await screen.findByTestId("repeater-fields"); + fireEvent.click(within(list).getByRole("button", { name: "Add item field" })); + const sub = await screen.findByTestId("sub-field-form"); + fireEvent.change(within(sub).getByRole("textbox", { name: "Label (English)" }), { + target: { value: "Role" }, + }); + expect(within(sub).queryByRole("switch", { name: "A value per language" })).toBeNull(); + fireEvent.click(screen.getByTestId("sub-field-apply")); + const rows = await within(list).findByTestId("sub-field-list"); + await waitFor(() => expect(within(rows).getByText("role")).toBeInTheDocument()); + const up = within(rows).getByRole("button", { name: "Move Role up" }); + fireEvent.click(up); + // Role is first now: its up button is off, so focus stays on its down button. + await waitFor(() => + expect(within(rows).getByRole("button", { name: "Move Role down" })).toHaveFocus(), + ); + fireEvent.click(screen.getByTestId("field-apply")); + fireEvent.click(await screen.findByTestId("save-fields")); + await waitFor(() => + expect( + bodyOf(fetch, "PUT", "/api/sites/site_1/collections/events")?.settings.fields[1].fields, + ).toEqual([ + { key: "role", type: "text", labels: { en: { label: "Role" } } }, + { key: "name", type: "text", labels: L("Name") }, + ]), + ); + }); +}); + +describe("Field operations › sub-fields and a stuck run", () => { + it("sends the sub-field and the new type's inputs", () => { + const target = { kind: "rename_field" as const, def: SPEAKERS }; + const base = { type: "text" as const, form: emptyForm("text", ["en"]) }; + expect(requestOf(target, { ...base, key: "fullName", sub: "name" }, false)).toEqual({ + op: "rename_field", + from: "speakers", + to: "fullName", + sub: "name", + }); + const toRef = { + key: "speakers", + sub: "name", + type: "reference" as const, + form: { ...emptyForm("reference", ["en"]), collections: ["people"] }, + }; + expect(requestOf({ ...target, kind: "change_field_type" }, toRef, true)).toEqual({ + op: "change_field_type", + key: "speakers", + type: "reference", + sub: "name", + collections: ["people"], + onInvalid: "clear", + }); + const toList = { + key: "venue", + sub: "", + type: "repeater" as const, + form: formOf(SPEAKERS, ["en"]), + }; + expect(requestOf({ kind: "change_field_type", def: VENUE }, toList, false)).toMatchObject({ + op: "change_field_type", + type: "repeater", + subFields: [{ key: "name", type: "text", labels: L("Name") }], + }); + }); + + it("stops on values that no longer convert, and finishes clearing them once confirmed", async () => { + const result = (done: boolean) => ({ + operationId: "snap_1", + dryRun: false, + done, + resumed: true, + report: { op: "change_field_type", entries: 2, touched: 1, unconvertible: 0, samples: [] }, + rewritten: 0, + skipped: [], + unconvertible: done ? [] : [{ entryId: "entry_a", reason: "target archived" }], + }); + const bodies: string[] = []; + const running = { + ...VENUE, + migration: { id: "snap_1", fromType: "text" }, + } as unknown as FieldDefinition; + serve( + instance(true), + registry, + on("GET", "/api/sites/site_1/collections/events/fields/operations/snap_1", { + status: 200, + body: { state: "running", pending: 1 }, + }), + (url, init) => { + if (init?.method !== "POST" || !url.includes("continue=snap_1")) return undefined; + const body = String(init.body); + bodies.push(body); + return { status: 200, body: result(body.includes("clear")) }; + }, + ); + render( + wrap( + , + ), + ); + fireEvent.click(await screen.findByRole("button", { name: "Continue" })); + const stuck = await screen.findByTestId("field-operation-stuck"); + expect(within(stuck).getByText("1 entry no longer converts")).toBeInTheDocument(); + const primary = screen.getByTestId("field-operation-primary"); + expect(primary).toHaveTextContent("Clear and finish"); + expect(primary).toBeDisabled(); + fireEvent.click(within(stuck).getByText(/Clear this value to finish/)); + fireEvent.click(primary); + await waitFor(() => expect(bodies.at(-1)).toBe('{"onInvalid":"clear"}')); + expect(await screen.findByText("Done")).toBeInTheDocument(); + }); +}); + +const HALL = { + schemaVersion: 1, + id: "entry_hall0001", + siteId: "site_1", + collection: "places", + slug: "main-hall", + locale: "tr", + status: "published", + title: "Ana salon", + body: { format: "markdown", value: "" }, + fields: {}, + tags: [], + author: { kind: "human", id: "u_owner" }, + translation: { group: "entry_hallen01", method: "human" }, + rev: 2, + createdAt: "2026-11-01T10:00:00Z", + updatedAt: "2026-11-01T10:00:00Z", +} as unknown as ContentEntry; +const card = (id: string, title: string, status: string, locale = "en", collection = "events") => ({ + groupId: id, + id, + title, + collection, + status, + locale, +}); +const siteRoute = on("GET", "/api/sites/site_1", { status: 200, body: SITE }); + +function Delete() { + return ( + {}} + siteId="site_1" + entry={HALL} + menuRefs={[]} + isDeleting={false} + onDelete={async () => {}} + /> + ); +} + +const deleteButton = () => screen.getByRole("button", { name: /^Delete/ }); +function confirmName() { + fireEvent.change(screen.getByRole("textbox", { name: /Type main-hall/ }), { + target: { value: "main-hall" }, + }); +} + +describe("Delete dialog › references (RFC-0045 §5)", () => { + it("keeps Delete off while published entries show it, and says archiving doesn't help", async () => { + serve(siteRoute, (url, init) => + init?.method === "DELETE" && url.includes("dryRun=1") + ? { + status: 409, + body: { + error: "entry_referenced", + message: "referenced", + details: { + group: "entry_hallen01", + total: 1, + referrers: [ + { id: "entry_ev1", title: "Launch", collection: "events", field: "venue" }, + ], + }, + }, + } + : undefined, + ); + render(wrap()); + const notice = await screen.findByTestId("delete-references"); + expect(within(notice).getByText("Unpublish or edit this entry first")).toBeInTheDocument(); + expect(within(notice).getByText(/Archiving it doesn't help/)).toBeInTheDocument(); + expect(within(notice).getByRole("link", { name: "Launch" })).toBeInTheDocument(); + confirmName(); + expect(deleteButton()).toBeDisabled(); + }); + + it("lists drafts that will show nothing when it is the group's last entry", async () => { + serve( + siteRoute, + (url, init) => + init?.method === "DELETE" && url.includes("dryRun=1") + ? { status: 200, body: { dryRun: true } } + : undefined, + on("GET", "/api/sites/site_1/entries/entry_hall0001/references", { + status: 200, + body: { + groupId: "entry_hallen01", + referrers: [{ ...card("entry_ev2", "Draft talk", "draft"), field: "venue", shownBy: [] }], + }, + }), + on("GET", "/api/sites/site_1/entries/groups", { + status: 200, + body: { + groups: { + entry_hallen01: [card("entry_hall0001", "Ana salon", "published", "tr", "places")], + }, + }, + }), + ); + render(wrap()); + const notice = await screen.findByTestId("delete-references"); + expect(within(notice).getByText("1 draft names this entry")).toBeInTheDocument(); + expect(within(notice).getByText("Will show nothing for this field")).toBeInTheDocument(); + confirmName(); + await waitFor(() => expect(deleteButton()).toBeEnabled()); + }); + + it("names the language that loses the card when other entries of the group stay", async () => { + serve( + siteRoute, + (url, init) => + init?.method === "DELETE" && url.includes("dryRun=1") + ? { status: 200, body: { dryRun: true } } + : undefined, + on("GET", "/api/sites/site_1/entries/entry_hall0001/references", { + status: 200, + body: { + groupId: "entry_hallen01", + referrers: [{ ...card("entry_ev1", "Launch", "published"), field: "venue", shownBy: [] }], + }, + }), + on("GET", "/api/sites/site_1/entries/groups", { + status: 200, + body: { + groups: { + entry_hallen01: [ + card("entry_hallen01", "Main hall", "published", "en", "places"), + card("entry_hall0001", "Ana salon", "published", "tr", "places"), + ], + }, + }, + }), + ); + render(wrap()); + const notice = await screen.findByTestId("delete-references-locale"); + expect(within(notice).getByText("Turkish pages lose this card")).toBeInTheDocument(); + expect(within(notice).getByText(/show the English version instead/)).toBeInTheDocument(); + }); +}); + +describe("Publish dialog › reference warnings", () => { + it("lists published entries whose references will show nothing", async () => { + serve( + on("GET", "/api/sites/site_1/entries/reference-issues", { + status: 200, + body: { + total: 1, + issues: [ + { + entryId: "entry_ev1", + title: "Launch", + collection: "events", + locale: "en", + path: "fields/speakers/0/name", + groupId: "entry_gone", + reason: "hidden", + }, + ], + }, + }), + ); + render(wrap( {}} />)); + const warnings = await screen.findByTestId("publish-reference-warnings"); + expect(within(warnings).getByText("1 reference will show nothing")).toBeInTheDocument(); + expect(within(warnings).getByRole("link", { name: "Launch" })).toHaveAttribute( + "href", + "/sites/site_1/collections/events/entry_ev1", + ); + expect( + within(warnings).getByText("English · Speakers: Name: the chosen entry isn't published"), + ).toBeInTheDocument(); + }); +}); + +// ── Review round 1 (PR #220) ────────────────────────────────────────────────────────────────── + +const okCheck = (url: string, init?: RequestInit) => + init?.method === "DELETE" && url.includes("dryRun=1") + ? { status: 200, body: { dryRun: true } } + : undefined; +const referrersRoute = (referrers: unknown[]) => + on("GET", "/api/sites/site_1/entries/entry_hall0001/references", { + status: 200, + body: { groupId: "entry_hallen01", referrers }, + }); +const groupsRoute = (members: unknown[]) => + on("GET", "/api/sites/site_1/entries/groups", { + status: 200, + body: { groups: { entry_hallen01: members } }, + }); + +describe("Review round 1 › the delete dialog", () => { + it("does not read the editor's cached groups as its own (another shape, its own key)", async () => { + serve(siteRoute, okCheck, referrersRoute([]), groupsRoute([])); + const qc = testQueryClient(); + // What the editor's reference fields cache for the same group (`useGroupCards`). + qc.setQueryData(["entry-groups", "site_1", "entry_hallen01"], { + entry_hallen01: [card("entry_hallen01", "Main hall", "published", "en", "places")], + }); + render(wrap(, "en", qc)); + confirmName(); + await waitFor(() => expect(deleteButton()).toBeEnabled()); + expect(qc.getQueryData(["entry-groups", "site_1", "entry_hallen01"])).toEqual({ + entry_hallen01: [expect.objectContaining({ id: "entry_hallen01" })], + }); + }); + + it("names a draft holder's published version as what keeps the entry", async () => { + serve( + siteRoute, + (url, init) => + init?.method === "DELETE" && url.includes("dryRun=1") + ? { + status: 409, + body: { + error: "entry_referenced", + message: "referenced", + details: { + group: "entry_hallen01", + total: 1, + referrers: [ + { id: "entry_ev1", title: "Launch", collection: "events", field: "venue" }, + ], + }, + }, + } + : undefined, + referrersRoute([ + { + ...card("entry_ev1", "Launch", "draft"), + field: "venue", + shownBy: [card("entry_ev1tr", "Lansman", "published", "tr")], + }, + ]), + ); + render(wrap()); + const notice = await screen.findByTestId("delete-references"); + expect( + await within(notice).findByText("Shown by its published version in Turkish"), + ).toBeInTheDocument(); + expect(within(notice).getByText("Draft")).toBeInTheDocument(); + expect(within(notice).getByText("Unpublish or edit this entry first")).toBeInTheDocument(); + }); + + it("counts only entries the referring fields allow as left", async () => { + serve( + siteRoute, + okCheck, + referrersRoute([ + { ...card("entry_ev2", "Draft talk", "draft"), field: "venue", shownBy: [] }, + ]), + // The other language version sits in a collection `venue` doesn't allow. + groupsRoute([ + card("entry_hallen01", "Main hall", "published", "en", "people"), + card("entry_hall0001", "Ana salon", "published", "tr", "places"), + ]), + ); + render(wrap()); + const notice = await screen.findByTestId("delete-references"); + expect(notice).toHaveAttribute("data-drafts"); + expect(within(notice).getByText("Will show nothing for this field")).toBeInTheDocument(); + }); + + it("shows the real delete's refusal in the dialog: its referrers, Delete off", async () => { + serve(siteRoute, okCheck, referrersRoute([]), groupsRoute([])); + const onDelete = vi.fn(async () => { + throw new ApiRequestError(409, { + error: "entry_referenced", + message: "referenced", + details: { + group: "entry_hallen01", + total: 1, + referrers: [ + { id: "entry_ev9", title: "Late launch", collection: "events", field: "venue" }, + ], + }, + }); + }); + render( + wrap( + {}} + siteId="site_1" + entry={HALL} + menuRefs={[]} + isDeleting={false} + onDelete={onDelete} + />, + ), + ); + confirmName(); + await waitFor(() => expect(deleteButton()).toBeEnabled()); + fireEvent.click(deleteButton()); + const notice = await screen.findByTestId("delete-references"); + expect(notice).toHaveAttribute("data-blocked"); + expect(await within(notice).findByRole("link", { name: "Late launch" })).toBeInTheDocument(); + expect(deleteButton()).toBeDisabled(); + }); +}); + +describe("Review round 1 › the builder and operations", () => { + it("retypes a select whose labels are only in another language", () => { + const def = { + key: "kind", + type: "select", + labels: L("Kind"), + localized: false, + options: [{ value: "hall", labels: { tr: "Salon" } }], + } as FieldDefinition; + const inputs = { + key: "kind", + sub: "", + type: "select" as const, + form: formOf(def, ["en", "tr"]), + }; + expect(targetProblems(inputs, "en")).toEqual({}); + expect(targetProblems({ ...inputs, form: { ...inputs.form, options: [] } }, "en")).toEqual({ + options: "required", + }); + }); + + it("drops conditions that don't fit a new target, and flags any left", async () => { + serve(instance(true), registry); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Venue" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const form = await screen.findByTestId("reference-fields"); + fireEvent.click(within(form).getByRole("button", { name: "Add condition" })); + const where = within(form).getByTestId("reference-where"); + expect(where.querySelector("[data-condition=kind]")).not.toBeNull(); + const targets = within(form).getByRole("group", { name: "Target collections" }); + fireEvent.click(within(targets).getByText("Places")); + fireEvent.click(within(targets).getByText("People")); + await waitFor(() => expect(where.querySelector("[data-condition]")).toBeNull()); + expect( + stage4Problems( + { + ...emptyForm("reference", ["en"]), + collections: ["people"], + where: [{ field: "kind", op: "set" }], + }, + "en", + () => [], + ), + ).toEqual({ where: "field" }); + }); + + it("keeps an unsaved item field's key editable, and the item name follows its new key", async () => { + const fetch = serve( + instance(true), + registry, + on("PUT", "/api/sites/site_1/collections/events", put), + ); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Speakers" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const list = await screen.findByTestId("repeater-fields"); + fireEvent.click(within(list).getByRole("button", { name: "Add item field" })); + let sub = await screen.findByTestId("sub-field-form"); + fireEvent.change(within(sub).getByRole("textbox", { name: "Label (English)" }), { + target: { value: "Role" }, + }); + fireEvent.click(screen.getByTestId("sub-field-apply")); + await waitFor(() => expect(screen.queryByTestId("sub-field-form")).toBeNull()); + fireEvent.click(within(list).getByRole("combobox", { name: "Item name" })); + const option = await screen.findByRole("option", { name: "Role" }); + fireEvent.pointerDown(option, { pointerType: "mouse" }); + fireEvent.click(option); + await waitFor(() => + expect(within(list).getByRole("combobox", { name: "Item name" })).toHaveTextContent("Role"), + ); + fireEvent.click(within(list).getByRole("button", { name: "Actions for Role" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + sub = await screen.findByTestId("sub-field-form"); + const key = within(sub).getByRole("textbox", { name: "Key" }); + expect(key).toBeEnabled(); + fireEvent.change(key, { target: { value: "part" } }); + fireEvent.click(screen.getByTestId("sub-field-apply")); + fireEvent.click(screen.getByTestId("field-apply")); + fireEvent.click(await screen.findByTestId("save-fields")); + await waitFor(() => + expect( + bodyOf(fetch, "PUT", "/api/sites/site_1/collections/events")?.settings.fields[1], + ).toMatchObject({ + itemLabel: "part", + fields: [{ key: "name" }, { key: "part" }], + }), + ); + }); + + it('keeps "Add condition" focusable at the limit (aria-disabled, not disabled)', async () => { + serve(instance(true), registry); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Venue" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const form = await screen.findByTestId("reference-fields"); + const add = within(form).getByRole("button", { name: "Add condition" }); + for (let i = 0; i < 5; i++) fireEvent.click(add); + expect( + within(form).getByTestId("reference-where").querySelectorAll("[data-condition]"), + ).toHaveLength(4); + expect(add).toHaveAttribute("aria-disabled", "true"); + expect(add).toBeEnabled(); + }); +}); + +describe("Review round 1 › publish warnings count references", () => { + it("says how many more references, not entries", async () => { + const issue = (n: number) => ({ + entryId: `entry_ev${n}`, + title: `Event ${n}`, + collection: "events", + locale: "en", + path: "fields/venue", + groupId: "entry_gone", + reason: "missing", + }); + serve( + on("GET", "/api/sites/site_1/entries/reference-issues", { + status: 200, + body: { total: 7, issues: [1, 2, 3, 4, 5, 6, 7].map(issue) }, + }), + ); + render(wrap( {}} />)); + expect(await screen.findByText("And 2 more references.")).toBeInTheDocument(); + }); +}); + +// ── Review round 2 (PR #220) ────────────────────────────────────────────────────────────────── + +describe("Review round 2", () => { + it("asks the store again when the dialog reopens after a refusal", async () => { + serve(siteRoute, okCheck, referrersRoute([]), groupsRoute([])); + const refuse = vi.fn(async () => { + throw new ApiRequestError(409, { + error: "entry_referenced", + message: "referenced", + details: { group: "entry_hallen01", total: 1, referrers: [] }, + }); + }); + const dialog = (open: boolean) => ( + {}} + siteId="site_1" + entry={HALL} + menuRefs={[]} + isDeleting={false} + onDelete={refuse} + /> + ); + const qc = testQueryClient(); + const { rerender } = render(wrap(dialog(true), "en", qc)); + confirmName(); + await waitFor(() => expect(deleteButton()).toBeEnabled()); + fireEvent.click(deleteButton()); + await waitFor(() => expect(deleteButton()).toBeDisabled()); + rerender(wrap(dialog(false), "en", qc)); + rerender(wrap(dialog(true), "en", qc)); + confirmName(); + await waitFor(() => expect(deleteButton()).toBeEnabled()); + }); + + it("keeps a reference's conditions when a second target is ticked and unticked", async () => { + serve(instance(true), registry); + render(wrap(
)); + fireEvent.click(await screen.findByRole("button", { name: "Actions for Venue" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit…" })); + const form = await screen.findByTestId("reference-fields"); + fireEvent.click(within(form).getByRole("button", { name: "Add condition" })); + const targets = within(form).getByRole("group", { name: "Target collections" }); + fireEvent.click(within(targets).getByText("People")); + fireEvent.click(within(targets).getByText("People")); + await waitFor(() => + expect(within(form).getByRole("checkbox", { name: "People" })).not.toBeChecked(), + ); + expect( + within(form).getByTestId("reference-where").querySelector("[data-condition=kind]"), + ).not.toBeNull(); + }); + + it("locks the target's settings while the operation can't take input", () => { + const def = { + key: "kind", + type: "text", + labels: L("Kind"), + localized: false, + } as FieldDefinition; + const inputs = { + key: "kind", + sub: "", + type: "select" as const, + form: { + ...emptyForm("select", ["en"]), + options: [{ value: "a", valueEdited: true, labels: { en: "A" } }], + }, + }; + render( + wrap( + k) as never} + target={{ kind: "change_field_type", def }} + inputs={inputs} + setInputs={() => {}} + keyError={undefined} + locales={["en"]} + defaultLocale="en" + uiLocale="en" + disabled + site={SITE} + referenceTypes + />, + ), + ); + const options = screen.getByTestId("field-options"); + expect(options.closest("fieldset")).toBeDisabled(); + }); + + it("decides per referring field: one loses the card, another shows nothing", async () => { + serve( + siteRoute, + okCheck, + referrersRoute([ + { ...card("entry_ev1", "Launch", "draft"), field: "venue", shownBy: [] }, + { ...card("entry_ev2", "Sponsor night", "draft"), field: "sponsor", shownBy: [] }, + ]), + groupsRoute([ + card("entry_hallen01", "Main hall", "published", "en", "places"), + card("entry_hall0001", "Ana salon", "published", "tr", "places"), + ]), + ); + render(wrap()); + expect(await screen.findByTestId("delete-references-locale")).toBeInTheDocument(); + const drafts = await screen.findByTestId("delete-references"); + expect(within(drafts).getByRole("link", { name: "Sponsor night" })).toBeInTheDocument(); + expect(within(drafts).queryByRole("link", { name: "Launch" })).toBeNull(); + }); + + it("counts every blocking reference the store counted, and says more remain", async () => { + serve( + siteRoute, + (url, init) => + init?.method === "DELETE" && url.includes("dryRun=1") + ? { + status: 409, + body: { + error: "entry_referenced", + message: "referenced", + details: { + group: "entry_hallen01", + total: 150, + referrers: [ + { id: "entry_ev1", title: "Launch", collection: "events", field: "venue" }, + ], + }, + }, + } + : undefined, + on("GET", "/api/sites/site_1/entries/entry_hall0001/references", { + status: 200, + body: { + groupId: "entry_hallen01", + next: 100, + referrers: [{ ...card("entry_ev1", "Launch", "published"), field: "venue", shownBy: [] }], + }, + }), + ); + render(wrap()); + const notice = await screen.findByTestId("delete-references"); + await waitFor(() => + expect( + within(notice).getByText("Unpublish or edit these 150 entries first"), + ).toBeInTheDocument(), + ); + expect(within(notice).getByText("And 149 more references.")).toBeInTheDocument(); + }); +}); diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 319503b0..3d2eeec9 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -27,7 +27,7 @@ Studio mirrors this one-to-one: | Sections | `SectionCard` (`@cloudhal/ui`): Kumo `LayerCard` with the `Secondary` header row (title, optional 16 px icon, `actions` right: count `Badge`, ghost `LinkButton` "View all", small buttons) and the `Primary` body (`description`, `children` in `grid gap-5 px-5 pt-4 pb-5`, `flush` for a `Table`, `footer` row for Save/Discard). Never nested (lint `layer-card-nesting`); never a bare `LayerCard` with hand-made headers. | | Neutral panels | `InsetPanel` (`@cloudhal/ui`, `outline` / `recessed`) for summaries and quoted agent text inside cards and dialogs ("Now → New", the agent's reason); `Surface` for standalone neutral surfaces. Hand-made `rounded-* ring` divs are forbidden in screens (lint `no-ad-hoc-surface`). | | Feedback | `Banner` (page/section), `Field`/`Input error` (inline), `Toasty` + `useKumoToastManager` (transient). | -| Confirmation | `ApprovalDialog` (ours, on `Dialog`) for publish/rollback; `DeleteResource` (block) for typed-name deletes; `LayerDialog.Alert` for other destructive alerts. Always rendered, controlled by `open`. | +| Confirmation | `ApprovalDialog` (ours, on `Dialog`) for publish/rollback; `DeleteResource` (block; Cloudhal adds `notice` and `deleteDisabled`) for typed-name deletes; `LayerDialog.Alert` for other destructive alerts. Always rendered, controlled by `open`. | | Forms & detail sheets | `LayerDialog.Root open onOpenChange` → `LayerDialog.Content` whose **direct** children are `Title`, optional `Description`, `Body`, optional `Actions` (Kumo throws otherwise; branch *inside* each part). Desktop dialog, bottom sheet on phones. | | Code & config | `CodeHighlighted` from `@cloudhal/ui/code` (Shiki, lazy, `showCopyButton`) for snippets and JSON payloads; screens import it through `components/CodeHighlighted.tsx`, which brings its own `ShikiProvider` (localised copy labels) so Kumo's code module and Shiki stay out of the shell's initial load. `Code` stays for one-line CLI hints in usage panels. | @@ -482,6 +482,10 @@ Words in the lazy `fields` namespace (loaded with the entry editor and collectio - **Entry editor › references and repeaters** (RFC-0045 §5; words in `fields.reference.*`, `fields.repeater.*`, `fields.usedBy.*`): a reference is `FieldReference` — `ReferenceList` of the stored targets (each group's member in the entry's language, else the default language's, else the first, read from `GET …/entries/groups`; any status, so drafts and archived ones say they will not show and deleted ones say "Not found") and an `EntryPicker` over `GET …/entries/pick` (the allowed collections, keyset cursor; the entry's own group and the chosen ones can't be picked); the trigger is secondary "Choose entry", "Change" for a chosen single target, "Add entries" with "Holds up to n entries." for several. A repeater is `RepeaterField` with each sub-field's `FieldControl` (images through `FieldImage`, references through `FieldReference`); its legend is `fields.editor.itemLegend` ("Item 2: Ada Lovelace", from `itemLabel`), "Remove" shows an info toast "Item 2: … removed" with "Undo" for 10 s (scoped to the entry: it closes when the editor leaves it; Undo writes over the current draft, and an error toast says when the list is full again), a save leaves out items that hold no value, a value a running type change has not converted shows read-only, as stored, with "Being changed", and a list that is not localized says "Not translated: this list is shared by every language." on multilingual sites. Save issues map by path (`fields/speakers/2/person` → that control). **Used by** (`UsedByCard`, a flush `SectionCard` with the reach cards after "In menus", outside the read-only fieldset so "Show more" works on an article the person can't edit, only when some field of the site can reference the collection): `UsedByList` from `GET …/entries/:id/references`, published referrers first, "Show more" while pages remain, `SectionEmpty` (`LinkSimple`) when none. Fields loads through `EntryFieldCards`, Used by through `EntryUsedByCard`, each as its own lazy chunk. Read-only, every repeater item is open. A save's item issues are mapped back onto the draft's items, since the save leaves empty items out. - **Collection settings › Fields** (`FieldsSection`, `SettingsSection #fields` after "Listing", in the table of contents; a skeleton section while its chunk loads): rows separated by hairlines — the label (bold), the key in mono, a secondary type `Badge`, info "Per language", outline "Needed to publish", beta "Not saved" for fields added in the draft, warning "Being changed" while an operation's marker is on it; ghost square up/down buttons and a ⋯ `DropdownMenu` (Edit…; for saved fields Rename…, Change type… (only types the conversion table allows), Languages…, Remove… (danger); for unsaved ones Remove). Header action: secondary `Plus` "Add field" (disabled at 30). Footer: ghost "Discard" while dirty, primary "Save fields" — one `PUT /collections/:key` with the whole list (free edits only). Operations wait until the draft is saved or discarded (a muted line says so). Empty: `SectionEmpty` (`Textbox`). Without `site:settings`, the page's `ReadOnly` fieldset and `readOnlyReason` disable every control. - **Add / edit field** (`FieldDialog`, `LayerDialog lg`): type `Select` (new fields only), label and help per site language (`Input` pairs, two columns from `sm`; the default language's label is required), the key (mono, derived from the default label until edited — `fieldKeyFrom` — and checked against the contract's pattern, the reserved keys and the collection's other keys; disabled once saved), `SwitchField`s "A value per language" (pre-set on for text, rich text and links) and "Needed to publish" (not for yes/no), then the type's own: maximum length, "Several lines" and "Include in site search" (text), search (rich text), minimum / maximum and "Whole numbers only" (number), options as `InsetPanel`s with a label per language and a mono value (derived from the default label until edited), "Add option" and "Allow several" (select). Errors show after the first Apply. A saved field keeps key, type, localization and `multiple` disabled with the reason: those change through operations. +- **Reference and list fields in the builder** (RFC-0045 §5, rollout step 6b; words in `fields.dialog.reference.*`, `fields.dialog.repeater.*`). Offered in the type `Select` only when `/api/instance` reports `features.referenceFields` (the server's store takes them; until rollout step 7 production doesn't, and the builder looks as before). **Reference**: "Target collections" as a `Checkbox.Group` of the site's collections (one to five), `SwitchField` "Several" (new fields only; a saved one changes it through "Change type…") with "At most" (1–50), and "Only entries where" — the page editor's condition rows (`ConditionRow`, `noNow`: dates are always a date) over the one target collection's listing fields, a muted line when there are several targets or no listing fields. **List**: "Fields of each item" as hairline rows (label bold, key mono, type `Badge`, beta "Not saved"), each with ghost square up/down and a ⋯ `DropdownMenu` (Edit…; Remove for an unsaved one, a disabled "Remove through the field's menu" for a saved one), focus kept on the moved row's arrow (the other one at an end) and moved to the next row, or "Add item field", after a removal; "Add item field" (up to ten) opens a nested `LayerDialog lg` with the field form in sub-field mode (sub-field types, no languages switch, search or "Several"; a reference sub-field takes target collections only); "At most" and "Item name" (`Select` of text, choice and reference item fields, "The first text, choice or reference" by default). Rows carry stable ids, never indexes. +- **Operations on lists and references**: for a list, "Applies to" (`Select`: the whole field or one item field) for rename, change type and remove; "Change type…" offers reference and list targets behind the same capability, and the target's own settings come from the builder's parts (options; target collections, "Several", "At most"; a list's item fields, "At most", "Item name"). A run or revert that can't finish because values stopped converting while it ran shows an alert `Banner` ("1 entry no longer converts", what happened), a `Checkbox` "Clear this value to finish. It is kept in the snapshot taken before the change." that must be ticked, and the primary becomes destructive "Clear and finish" (`onInvalid: "clear"` on the continue or revert). +- **Delete dialog and references** (`EntryDeleteDialog`, the editor and the articles list; `fields/DeleteReferences.tsx` lazily where some field can reference the collection): the store answers first (`DELETE …?dryRun=1`). While it refuses (`entry_referenced`), Delete stays disabled (`DeleteResource deleteDisabled`), an error `Banner` "Unpublish or edit these n published entries first" says that archiving doesn't help, and `UsedByList` lists them. Otherwise, when no other entry of the group is left, an alert `Banner` lists draft referrers, each "Will show nothing for this field"; when others are left, an alert `Banner` names the language whose pages lose the card (or show the default language's version under `default-with-notice`). The check running keeps Delete off; a failed check leaves it on (the delete is guarded). +- **Publish dialog** (`SitePublish`): for a site with reference or list fields, a lazy `PublishWarnings` under the unpublished-pages note: an alert `Banner sm` "n references will show nothing" ("Publishing goes ahead…") and an `InsetPanel` with up to five holders (a `Link` to the editor, then language · field: why — gone, not published, no version in this language), from `GET …/entries/reference-issues`. - **Operation dialogs** (`FieldOperationDialog`, `LayerDialog lg`, one for rename, change type, languages and remove): the inputs (new key; new type and, for a select, its options), then primary "Check what changes" runs the dry run (languages and removal run it on open). The report (`FieldOperationReport`): an outline `InsetPanel` `dl` of entries checked and changed and the non-zero counts (moved, converted, cleared, removed, can't be converted in danger text), per language on multilingual sites, and up to five recessed sample panels (kind `Badge`, entry id · locale in mono, before → after with a `DirectionalIcon` arrow). Then an alert `Banner` about themes reading `entry.fields.` (rename, type), the `Checkbox` "Clear n values that can't be converted" that must be ticked when the dry run found any, and the `Camera` snapshot line ("…the live site changes only when you publish"). The primary becomes the operation's verb ("Rename field", "Remove field"); running shows a `Loader` with the entries updated so far while Studio continues every `done: false`; finished, a `Banner` "Done · n entries changed." with `Banner.Action` "Revert", then "Reverted". The dialog can't be dismissed while it runs. A field carrying a running operation's marker puts an alert `Banner` in the section ("A field change is still running · n entries left", polled) with "Continue", which reopens the dialog on that run (or its revert). - **Collection settings › Listing fields, Expiry, Structured data** (RFC-0042 §7, ADR-0044; `components/listings/`, words in the lazy `listings` namespace, `LAZY_ONLY` with `lib/listings.ts`, `lib/listing-query.ts`, `lib/api/listings.ts` and `@cloudhal/core-ts/structured-data`): three `SettingsSection`s after Fields, in the table of contents (`#listing-fields`, `#expiry`, `#structured-data`), one lazy chunk with a skeleton section while it loads. Each saves only its own setting (`PUT /collections/:key` with the current settings and that key replaced): footer ghost "Discard" while dirty, primary "Save …" (gated, `title` = reason); without `site:settings` the page's `ReadOnly` fieldset and `readOnlyReason` disable every control. **Listing fields**: up to four chosen fields as hairline rows (label bold, key mono, secondary type `Badge`, outline "Sortable" for number/date/datetime; ghost square up / down / remove), then a `Select` "Add a listing field" (disabled at four, with the reason as its description); empty → `SectionEmpty` (`Columns`); a 409 `list_field_in_use` shows the error `Banner` and links to the pages whose content lists still use the field. **Expiry**: `SwitchField` "End entries on a date", the date field `Select` (date and date-and-time fields only; none → a muted line), the action as `Radio.Group appearance="card"` ("Leave lists" / "Hide the page", each with RFC-0042 §3.3 in words), the grace in minutes (`Input type=number`, 0–10,080), and a live secondary line counting published entries already past it (counted in Studio with `fieldGateInstant`). A 409 `gates_unsupported` becomes an alert `Banner` (`CloudArrowUp`) that says to deploy the site first. **Structured data**: a type `Select` (None = blog post, Event, Job posting, Product, Recipe), then hairline rows of the type's properties — required first (warning `Badge` "Required", outline "One of these" for address groups) — each path in mono with a `Select` of only the sources that fit (its default, fields of a fitting type, entry and site properties, "A fixed value" where allowed; a fixed value on an enum path is a vocabulary `Select`, a currency gets the ISO hint). `checkPreset` runs while editing and the refused save's `details.issues` join it, worded per code under their row (`Text variant="error"`). Muted lines suggest "Needed to publish" for fields behind required properties and remind that the theme should show mapped fields. With a saved preset, an `InsetPanel` previews a chosen entry (`GET …/entries/:id/structured-data`): issues, the JSON-LD in `CodeHighlighted`, and for a published entry on a domain a secondary `LinkButton` to Google's Rich Results Test. - **Create listing page** (RFC-0042 §5): a secondary header action on collection settings (`pages.write` only) that creates a draft page at the collection's prefix titled with its plural, holding one `contentList` (upcoming first, soonest first, when the collection has a date listing field and an expiry), then opens it in the page editor. When a page already lives there it becomes "Open listing page" (`LinkButton`, the address in its `title`); a refused write is an error toast. diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md index 2ddb2ffc..a1c200d7 100644 --- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md +++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md @@ -1419,3 +1419,30 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r - **Duplicate** copies the fields without empty repeater items too (`duplicateOf` runs the same `withoutEmptyItems` as a save). - **The index map is built from what the failed save sent** (the mutation's variables), not from the draft when the error arrives, which may have changed while the save was in flight. + +### Amended in 6b (2026-11-13, rollout step 6, second PR: builder, delete dialog, publish warnings) + +- **The capability Studio reads** is `/api/instance` → `features.referenceFields`, true when the store has `SiteGraphStoreOptions.fieldTypes` (MCP's rule from step 5). The builder offers reference and list (repeater) only then; production answers false until step 7. A per-site refusal still comes from the store (`fields_unsupported`, now worded in `errors.api`). +- **The delete dialog asks the store.** `DELETE …/entries/:id?rev=&dryRun=1` runs the store's delete plan (`deleteContentEntry(entry, { dryRun })`) and answers its refusal (`entry_referenced` with up to 20 referrers and the total) or `{ dryRun: true }`. Studio's dialog keeps Delete off while the check runs and while it refuses, and lists the referrers from the refusal's details. Draft referrers ("Will show nothing for this field") and the language that loses the card come from `…/references` and `…/groups`. +- **Unlinking a translation** has no action in Studio today (translations are created, not unlinked); the store's `entry_referenced` and `translation_group_in_use` refusals of a group change are worded in `errors.api` for any path that reaches them. A Studio unlink action, when it comes, reuses the delete dialog's check through a dry-run update. +- **Publish warnings** come from a new read-only `GET …/entries/reference-issues` (`content:read`): `referenceIssues` over the working copy at the request's instant, the first 50 with each holder's title, and the total; a site without reference fields answers none without reading entries. They show in the site's publish dialog only (the entry editor's "Publish" and snapshot publishes keep RFC-0042's checks). +- **Stuck operations**: a pass that changed nothing while `unconvertible` (or a revert's) is not empty stops instead of looping; the dialog offers "Clear and finish" behind a ticked confirmation, which sends `onInvalid: "clear"` on the continue or the revert. +- **`docs/guides/fields.md`** is left to step 7, which enables the types. + +#### Review round 1 (2026-11-13, PM review of PR #220) + +- **The delete dialog reads its own data.** Group members are cached under their own key (the editor caches `…/groups` answers in another shape). The blocked list shows each holder's real status and the published language versions that show its value (`…/references`' `shownBy`), so a draft original whose translation is published names that translation. "Other entries left" counts only members in a collection some referring field allows. A refusal of the real delete (a stale check, `field_operation_running`) shows in the dialog: its referrers as the blocked state, any other code in its words. +- **"Change type…" checks the contract's rules and RFC-0045's**, not the builder's default-language labels, and shows its problems inline: a select whose labels are only in another language can be retyped. +- **A reference's conditions follow its target**: a new single target keeps only conditions on its own listing fields, and the form flags any that remain. +- **Item fields not saved yet** keep their key and type editable when edited; the item name follows a renamed item field (its options are keyed by the rows' stable ids) and lets go of one whose new type can't name items. +- **Limits keep focus**: "Add condition" and "Add item field" stay focusable (`aria-disabled`) at their limits. +- **A stuck operation** locks its inputs and announces itself (`role="alert"`). +- **Counts say references** where they count references ("And 2 more references."). + +#### Review round 2 (2026-11-13, PM verification of 169adfd9) + +- **A delete refusal belongs to one opening** of the dialog for one revision: reopening it, or a new revision, asks the store again. +- **Conditions are dropped only for a new single target**; ticking a second collection, or unticking and re-ticking the only one, keeps them. +- **A type change's target settings lock** (a disabled `fieldset`) whenever its inputs do: running, stuck or finished. +- **"Other entries left" is decided per referring field**, as the store decides: referrers whose field has no usable other member are listed as showing nothing, the others name the language that loses the card; both can show. +- **The blocked count is the store's** (`total`, never capped at the list's 100), the "more references" note shows whenever rows remain unlisted, and a late refusal reads the referrers again. diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 0833e3e8..ceb4f91a 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -218,14 +218,14 @@ export function createApi(deps: ApiDeps) { analytics: deps.analytics?.feature() ?? ANALYTICS_OFF, // RFC-0007 §12: whether comments are served, bot protections, purges waiting. comments: await commentsFeature(deps.comments), - // RFC-0008 §13: whether schedules are served, the ticker, gates, the limits. - // RFC-0016: how this deployment reaches Workers AI (the binding, REST, or not at all). - ai: deps.ai?.transport?.kind ?? "none", + ai: deps.ai?.transport?.kind ?? "none", // RFC-0016: the AI binding, REST, or none schedules: schedulesFeature( + // RFC-0008 §13: whether schedules are served, the ticker, gates, the limits. schedules, schedulesAvailable(schedules) && (await gatesEnforcedEverywhere(deps.store)), ), ...(playground ? { playground: await playground.info() } : {}), // RFC-0036 §5 + referenceFields: deps.store.fieldTypes !== undefined, // RFC-0045, MCP's rule (step 5) }, agentModes: agentModesInfo(), roles: rolesInfo(), diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts index d501910c..9f8499f6 100644 --- a/packages/api/src/playground.ts +++ b/packages/api/src/playground.ts @@ -147,6 +147,7 @@ export const PLAYGROUND_ALLOW: ReadonlySet = new Set([ // RFC-0045 §5: the entry picker, chosen targets and an entry's referrers, read-only (content:read). `GET ${S}/entries/pick`, `GET ${S}/entries/groups`, + `GET ${S}/entries/reference-issues`, `GET ${S}/entries/:entryId/references`, `GET ${S}/entries/:entryId`, `PUT ${S}/entries/:entryId`, diff --git a/packages/api/src/routes/entries.ts b/packages/api/src/routes/entries.ts index f4bd1052..97d43710 100644 --- a/packages/api/src/routes/entries.ts +++ b/packages/api/src/routes/entries.ts @@ -199,6 +199,9 @@ export const entries: Hono = new Hono() { error: "conflict", message: `entry ${entry.id} is at rev ${entry.rev}` }, 409, ); - await store.deleteContentEntry({ ...entry, rev }); - return c.body(null, 204); + // RFC-0045 §5: Studio's delete dialog asks first; the store's own plan answers (refusals such + // as `entry_referenced` with their referrers), and nothing is deleted. + const dryRun = c.req.query("dryRun") === "1"; + await store.deleteContentEntry({ ...entry, rev }, { dryRun }); + return dryRun ? c.json({ dryRun: true }) : c.body(null, 204); }); diff --git a/packages/api/src/routes/entry-references.ts b/packages/api/src/routes/entry-references.ts index cc06362f..ea304a97 100644 --- a/packages/api/src/routes/entry-references.ts +++ b/packages/api/src/routes/entry-references.ts @@ -11,13 +11,23 @@ * in this site, any status, as `{groupId, id, title, collection, status, locale}` cards (the * membership rule, `getGroupMembers`); a group with no member left gets an empty list. At most * 100 ids, the per-entry reference cap. + * - `GET …/entries/reference-issues` (rollout step 6, Studio's publish dialog): what publishing + * the working copy now would show as nothing, `referenceIssues` over it ("publish warns, never + * fails"), each with the holder's title; the first 50 and the total. A site without reference + * fields answers none without reading its entries. * - `GET …/entries/:entryId/references?limit=&offset=`: the referrers of the entry's translation * group from `entry_refs`, published holders first, each holder with the same-site members that * show its value (`shared` 1, or 2 without their own value), paged (`listReferrers`, which MCP's * `list_references` shares). */ import { getGroupMembers, listReferrers, referenceCard } from "@cloudhal/core-ts"; -import { FIELD_LIMITS, GROUP_ID_PATTERN, memberGroup } from "@cloudhal/core-ts/fields"; +import { + FIELD_LIMITS, + GROUP_ID_PATTERN, + hasReferenceFields, + memberGroup, + referenceIssues, +} from "@cloudhal/core-ts/fields"; import type { Context } from "hono"; import { Hono } from "hono"; import type { Env } from "../context.ts"; @@ -35,6 +45,7 @@ const PICK_LIMIT = 20; /** Picker searches per member and site per minute. */ const PICK_WINDOW = { hits: 120, ms: 60_000 }; const REFERRERS_LIMIT = 100; +const ISSUES_SHOWN = 50; const GROUP_ID = new RegExp(GROUP_ID_PATTERN); /** The groups' query: one to 100 well-formed group ids. */ @@ -98,6 +109,21 @@ export const entryReferences: Hono = new Hono() groups: Object.fromEntries([...members].map(([g, list]) => [g, list.map(referenceCard)])), }); }) + .get("/reference-issues", async (c) => { + const store = c.get("store"); + const site = await store.getSite(param(c, "siteId")); + if (!site) throw notFound("site not found"); + if (!hasReferenceFields(site)) return c.json({ total: 0, issues: [] }); + const entries = await store.listContentEntries(site.id); + const issues = referenceIssues({ site, entries }, c.get("now")().getTime()); + const titles = new Map(entries.map((e) => [e.id, e.title])); + return c.json({ + total: issues.length, + issues: issues + .slice(0, ISSUES_SHOWN) + .map((i) => ({ ...i, title: titles.get(i.entryId) ?? "" })), + }); + }) .get("/:entryId/references", async (c) => { const store = c.get("store"); const entry = await store.getContentEntry(param(c, "entryId")); diff --git a/packages/api/src/routes/instance.ts b/packages/api/src/routes/instance.ts index cd76439f..7466b8ab 100644 --- a/packages/api/src/routes/instance.ts +++ b/packages/api/src/routes/instance.ts @@ -63,6 +63,11 @@ export interface InstanceInfo { schedules: SchedulesFeature; /** RFC-0036 §5: set only in playground mode (the session's end, Reset and Deploy links). */ playground?: PlaygroundInfo; + /** + * RFC-0045: the store accepts reference and repeater fields (`SiteGraphStoreOptions.fieldTypes`), + * so Studio's fields builder offers them; the store still decides per site. + */ + referenceFields: boolean; }; /** The agent modes an API key can hold and what each defaults to (ADR-0015). */ agentModes: AgentModesInfo; diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index b0e4944e..09f26875 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -175,6 +175,7 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] // RFC-0045 §5: the entry picker and an entry's referrers ["GET", `${S}/entries/pick`, "content:read"], ["GET", `${S}/entries/groups`, "content:read"], + ["GET", `${S}/entries/reference-issues`, "content:read"], ["GET", `${S}/entries/:entryId/references`, "content:read"], ["GET", `${S}/entries/:entryId`, "content:read"], ["PUT", `${S}/entries/:entryId`, "content:draft:own"], diff --git a/packages/api/src/testing-references.ts b/packages/api/src/testing-references.ts index 39dcb65a..9fd34c27 100644 --- a/packages/api/src/testing-references.ts +++ b/packages/api/src/testing-references.ts @@ -241,5 +241,56 @@ export function describeReferencesApi(label: string, factory: ApiStoreFactory): expect(bad.status).toBe(422); expect(bad.body.error).toBe("unknown_entry"); }); + + it("asks a delete first (dryRun=1): the store's refusal with its referrers, and nothing deleted", async () => { + const hall = await entry("places", { title: "Main Hall" }); + const garden = await entry("places", { title: "Garden" }); + const event = await entry("events", { + title: "Launch", + status: "published", + fields: { venue: hall.id }, + }); + const ask = await call(`/api/sites/${site.id}/entries/${hall.id}?rev=${hall.rev}&dryRun=1`, { + method: "DELETE", + }); + expect(ask.status).toBe(409); + expect(ask.body.error).toBe("entry_referenced"); + const details = ask.body.details as { total: number; referrers: { id: string }[] }; + expect(details.total).toBe(1); + expect(details.referrers.map((r) => r.id)).toEqual([event.id]); + const free = await call( + `/api/sites/${site.id}/entries/${garden.id}?rev=${garden.rev}&dryRun=1`, + { method: "DELETE" }, + ); + expect(free.status).toBe(200); + expect(free.body).toEqual({ dryRun: true }); + expect(await store.getContentEntry(garden.id)).toBeDefined(); + }); + + it("lists what publishing would show as nothing, with the holder's title", async () => { + const hall = await entry("places", { title: "Main Hall" }); + const event = await entry("events", { + title: "Launch", + status: "published", + fields: { venue: hall.id }, + }); + const res = await call(`/api/sites/${site.id}/entries/reference-issues`); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.total).toBe(1); + expect(res.body.issues).toEqual([ + expect.objectContaining({ + entryId: event.id, + title: "Launch", + path: "fields/venue", + groupId: hall.id, + reason: "hidden", + }), + ]); + }); + + it("reports the field-type capability to Studio", async () => { + const res = await call("/api/instance"); + expect((res.body.features as { referenceFields?: boolean }).referenceFields).toBe(true); + }); }); } diff --git a/packages/api/test/studio-endpoints.test.ts b/packages/api/test/studio-endpoints.test.ts index 94b1f123..0cd97829 100644 --- a/packages/api/test/studio-endpoints.test.ts +++ b/packages/api/test/studio-endpoints.test.ts @@ -560,6 +560,8 @@ describe("instance and skills", () => { publishNow: false, limits: { maxItems: 100, horizonDays: 366, requestHorizonDays: 30, lateMinutes: 60 }, }, + // RFC-0045: a store without the field-type capability (production until step 7) + referenceFields: false, // RFC-0006 §8: a server without analytics says so analytics: { enabled: false, diff --git a/packages/ui/src/blocks/kumo/delete-resource/delete-resource.tsx b/packages/ui/src/blocks/kumo/delete-resource/delete-resource.tsx index 93fa4beb..55527222 100644 --- a/packages/ui/src/blocks/kumo/delete-resource/delete-resource.tsx +++ b/packages/ui/src/blocks/kumo/delete-resource/delete-resource.tsx @@ -11,8 +11,9 @@ import { import { Check, Copy, WarningCircle, X } from "@phosphor-icons/react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; -// Copied with `kumo add DeleteResource` and owned by Cloudhal. Changes from upstream: `notice`, and -// every word comes from `labels` (upstream hard-codes English; AGENTS.md hard rule 10). +// Copied with `kumo add DeleteResource` and owned by Cloudhal. Changes from upstream: `notice`, +// `deleteDisabled`, and every word comes from `labels` (upstream hard-codes English; AGENTS.md +// hard rule 10). export const KUMO_DELETE_RESOURCE_VARIANTS = { size: { @@ -79,6 +80,8 @@ export interface DeleteResourceProps extends KumoDeleteResourceVariantsProps { errorMessage?: string; /** Cloudhal: a consequence to read before confirming (e.g. the menu items that will be hidden). */ notice?: ReactNode; + /** Cloudhal: the delete can't go ahead now (the `notice` says why, e.g. published referrers). */ + deleteDisabled?: boolean; } export function DeleteResource({ @@ -92,6 +95,7 @@ export function DeleteResource({ size = KUMO_DELETE_RESOURCE_DEFAULT_VARIANTS.size, errorMessage, notice, + deleteDisabled = false, className, }: DeleteResourceProps) { const [confirmationInput, setConfirmationInput] = useState(""); @@ -113,9 +117,9 @@ export function DeleteResource({ normalizeForComparison(confirmationInput) === normalizeForComparison(resourceName); const handleDelete = useCallback(async () => { - if (!isConfirmed || isDeleting) return; + if (!isConfirmed || isDeleting || deleteDisabled) return; await onDelete(); - }, [isConfirmed, isDeleting, onDelete]); + }, [isConfirmed, isDeleting, deleteDisabled, onDelete]); const handleCopy = useCallback(async () => { await navigator.clipboard.writeText(resourceName); @@ -213,7 +217,7 @@ export function DeleteResource({