From ca222a0325af90022ac052b89bd6df25b4343d60 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Fri, 22 May 2026 15:30:19 +0100 Subject: [PATCH 1/3] feat: bylines i18n support --- .../admin/src/components/ContentEditor.tsx | 77 ++- packages/admin/src/lib/api/bylines.ts | 58 ++ packages/admin/src/lib/api/index.ts | 3 + packages/admin/src/router.tsx | 54 +- packages/admin/src/routes/bylines.tsx | 461 ++++++++----- .../tests/components/ContentEditor.test.tsx | 35 + packages/admin/tests/router-search.test.ts | 25 + .../tests/routes/bylines-load-more.test.ts | 65 ++ packages/auth/src/rbac.ts | 4 + packages/core/src/api/handlers/bylines.ts | 161 +++++ packages/core/src/api/handlers/content.ts | 182 ++++-- packages/core/src/api/schemas/bylines.ts | 46 ++ packages/core/src/astro/integration/routes.ts | 5 + .../routes/api/admin/bylines/[id]/index.ts | 15 +- .../api/admin/bylines/[id]/translations.ts | 99 +++ .../astro/routes/api/admin/bylines/index.ts | 33 +- packages/core/src/bylines/index.ts | 188 ++++-- .../database/migrations/040_byline_i18n.ts | 497 ++++++++++++++ .../core/src/database/migrations/runner.ts | 2 + .../core/src/database/repositories/byline.ts | 370 +++++++++-- .../core/src/database/repositories/types.ts | 13 + packages/core/src/database/types.ts | 15 + packages/core/src/query.ts | 16 +- .../integration/database/migrations.test.ts | 1 + .../tests/unit/api/content-handlers.test.ts | 320 +++++++++ .../tests/unit/api/handlers/bylines.test.ts | 298 +++++++++ .../tests/unit/bylines/bylines-query.test.ts | 273 ++++++++ .../migrations/040_byline_i18n.test.ts | 438 +++++++++++++ .../unit/database/repositories/byline.test.ts | 610 ++++++++++++++++++ 29 files changed, 4043 insertions(+), 321 deletions(-) create mode 100644 packages/admin/tests/router-search.test.ts create mode 100644 packages/admin/tests/routes/bylines-load-more.test.ts create mode 100644 packages/core/src/api/handlers/bylines.ts create mode 100644 packages/core/src/astro/routes/api/admin/bylines/[id]/translations.ts create mode 100644 packages/core/src/database/migrations/040_byline_i18n.ts create mode 100644 packages/core/tests/unit/api/handlers/bylines.test.ts create mode 100644 packages/core/tests/unit/database/migrations/040_byline_i18n.test.ts diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index df9cc0cf5..660f63229 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -109,6 +109,13 @@ export interface ContentEditorProps { item?: ContentItem | null; fields: Record; isNew?: boolean; + /** + * Locale this entry is bound to. For existing entries this matches + * `item.locale`; for new entries it's the URL `?locale=` (or default). + * Threaded into the byline picker so the empty-state CTA links to the + * right locale on the Bylines manager. + */ + entryLocale?: string | null; isSaving?: boolean; onSave?: (payload: { data: Record; @@ -196,6 +203,7 @@ export function ContentEditor({ item, fields, isNew, + entryLocale, isSaving, onSave, onAutosave, @@ -238,6 +246,11 @@ export function ContentEditor({ item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ?? [], ); + // Only send `bylines` when the user actually touched the editor. Strict + // per-locale hydration can return `item.bylines = []` for entries with + // copied credits at other locales — sending `[]` on every save would + // silently wipe them. Mirrors the `slugTouched` pattern above. + const [bylinesTouched, setBylinesTouched] = React.useState(false); // Track portableText editor for document outline. Only the "content" // field wires its editor into this slot (see onEditorReady below). @@ -311,6 +324,7 @@ export function ContentEditor({ }), ); pendingAutosaveStateRef.current = null; + setBylinesTouched(false); } // Update form and last saved state when item changes (e.g., after save or restore) @@ -338,6 +352,7 @@ export function ContentEditor({ }), ); pendingAutosaveStateRef.current = null; + setBylinesTouched(false); } }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); @@ -345,6 +360,7 @@ export function ContentEditor({ const handleBylinesChange = React.useCallback( (next: BylineCreditInput[]) => { + setBylinesTouched(true); if (isNew) { onBylinesChange?.(next); return; @@ -416,15 +432,19 @@ export function ContentEditor({ // Schedule autosave autosaveTimeoutRef.current = setTimeout(() => { if (hasInvalidUrls(formDataRef.current)) return; - const payload = { + const payload: { + data: Record; + slug?: string; + bylines?: BylineCreditInput[]; + } = { data: formDataRef.current, slug: slugRef.current || undefined, - bylines: activeBylines, }; + if (bylinesTouched) payload.bylines = activeBylines; pendingAutosaveStateRef.current = serializeEditorState({ data: payload.data, slug: payload.slug || "", - bylines: payload.bylines, + bylines: activeBylines, }); onAutosave(payload); }, AUTOSAVE_DELAY); @@ -443,6 +463,7 @@ export function ContentEditor({ isSaving, isAutosaving, activeBylines, + bylinesTouched, hasInvalidUrls, ]); @@ -455,11 +476,16 @@ export function ContentEditor({ clearTimeout(autosaveTimeoutRef.current); autosaveTimeoutRef.current = null; } - onSave?.({ + const payload: { + data: Record; + slug?: string; + bylines?: BylineCreditInput[]; + } = { data: formData, slug: slug || undefined, - bylines: activeBylines, - }); + }; + if (isNew || bylinesTouched) payload.bylines = activeBylines; + onSave?.(payload); }; // Preview URL state @@ -971,6 +997,10 @@ export function ContentEditor({ onChange={handleBylinesChange} onQuickCreate={onQuickCreateByline} onQuickEdit={onQuickEditByline} + // Existing entry: use its own locale. New entry: use the + // URL `?locale=` (passed in via `entryLocale`). + entryLocale={item?.locale ?? entryLocale} + i18n={i18n} /> )} @@ -1894,6 +1924,15 @@ interface BylineCreditsEditorProps { bylineId: string, input: { slug: string; displayName: string }, ) => Promise; + /** + * Locale of the entry being edited. When the picker comes back empty and + * the install is multi-locale, the empty-state copy and CTA link are + * scoped to this locale (post-migration 040, the picker is strict + * per-locale — see the bylines manager flow). + */ + entryLocale?: string | null; + /** i18n config from the manifest. When set with >1 locales, the editor renders the locale-scoped empty-state. */ + i18n?: { defaultLocale: string; locales: string[] } | null; } function BylineCreditsEditor({ @@ -1902,6 +1941,8 @@ function BylineCreditsEditor({ onChange, onQuickCreate, onQuickEdit, + entryLocale, + i18n, }: BylineCreditsEditorProps) { const { t } = useLingui(); const [selectedBylineId, setSelectedBylineId] = React.useState(""); @@ -1949,8 +1990,32 @@ function BylineCreditsEditor({ setEditError(null); }; + // Multi-locale install with no bylines available at the entry's locale: + // show an honest empty-state CTA pointing the editor at the byline + // manager, scoped to this locale. The picker stays rendered (the + // editor can still quick-create) but is empty until a per-locale + // variant exists. Mirrors the strict per-locale model from migration + // 040 — the picker only offers bylines that will actually hydrate. + const isMultiLocale = !!i18n && i18n.locales.length > 1; + const showLocaleEmptyState = isMultiLocale && bylines.length === 0 && !!entryLocale; + return (
+ {showLocaleEmptyState && ( +
+

+ {t`No bylines available in ${entryLocale}. Create a variant from the Bylines page before crediting one on this entry.`} +

+ + {t`Manage bylines in ${entryLocale}`} + +
+ )}
setSearch(e.target.value)} +
+ {isMultiLocale && i18n && activeLocale ? ( +
+
+

{t`Bylines`}

+
+ -
-
- setSearch(e.target.value)} + /> +
+
+ setForm((prev) => ({ ...prev, displayName: e.target.value }))} - /> - setForm((prev) => ({ ...prev, slug: e.target.value }))} - /> - setForm((prev) => ({ ...prev, websiteUrl: e.target.value }))} - /> - setForm((prev) => ({ ...prev, bio: e.target.value }))} - rows={5} - /> - setForm((prev) => ({ ...prev, displayName: e.target.value }))} + /> + setForm((prev) => ({ ...prev, slug: e.target.value }))} + /> + setForm((prev) => ({ ...prev, websiteUrl: e.target.value }))} + /> + setForm((prev) => ({ ...prev, bio: e.target.value }))} + rows={5} + /> +